diff --git a/docs/commands.md b/docs/commands.md index 4ff3e18..e5e5776 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -30,7 +30,7 @@ forge init [name] [flags] | `--tools` | | | Builtin tools to enable (e.g., `web_search,http_request`) | | `--skills` | | | Registry skills to include (e.g., `github,weather`) | | `--api-key` | | | LLM provider API key | -| `--from-skills` | | | Path to a skills.md file for auto-configuration | +| `--from-skills` | | | Path to a SKILL.md file for auto-configuration | | `--non-interactive` | | `false` | Skip interactive prompts | ### Examples @@ -48,7 +48,7 @@ forge init my-agent \ --non-interactive # From a skills file -forge init my-agent --from-skills skills.md +forge init my-agent --from-skills SKILL.md # With builtin tools and registry skills forge init my-agent \ diff --git a/docs/skills.md b/docs/skills.md index ef4b709..b4dcd74 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -4,11 +4,11 @@ Skills are a progressive disclosure mechanism for defining agent capabilities in ## Overview -Skills bridge the gap between high-level capability descriptions and the tool-calling system. A `skills.md` file in your project root defines what the agent can do, and Forge compiles these into JSON artifacts and prompt text for the container. +Skills bridge the gap between high-level capability descriptions and the tool-calling system. A `SKILL.md` file in your project root defines what the agent can do, and Forge compiles these into JSON artifacts and prompt text for the container. ## SKILL.md Format -Skills are defined in a Markdown file (default: `skills.md`). The file supports optional YAML frontmatter and two body formats. +Skills are defined in a Markdown file (default: `SKILL.md`). The file supports optional YAML frontmatter and two body formats. ### YAML Frontmatter @@ -89,7 +89,7 @@ The skill compilation pipeline has three stages: The `SkillsStage` (`internal/build/skills_stage.go`) runs as part of the build pipeline: -1. Resolves the skills file path (default: `skills.md` in work directory) +1. Resolves the skills file path (default: `SKILL.md` in work directory) 2. Skips silently if the file doesn't exist 3. Parses, compiles, and writes artifacts 4. Updates the `AgentSpec` with `skills_spec_version` and `forge_skills_ext_version` @@ -106,7 +106,7 @@ In `forge.yaml`: ```yaml skills: - path: skills.md # default, can be customized + path: SKILL.md # default, can be customized ``` ## CLI Workflow diff --git a/forge-cli/build/integration_test.go b/forge-cli/build/integration_test.go index a0cf337..48541ac 100644 --- a/forge-cli/build/integration_test.go +++ b/forge-cli/build/integration_test.go @@ -39,10 +39,10 @@ func TestIntegration_BuildWithSkillsAndEgress(t *testing.T) { outDir := t.TempDir() workDir := root - // Create a skills.md in a temp work dir + // Create a SKILL.md in a temp work dir skillsDir := t.TempDir() skillsContent := []byte("## Tool: web_search\nSearch the web for information.\n\n**Input:** query: string\n**Output:** results: []string\n") - if err := os.WriteFile(filepath.Join(skillsDir, "skills.md"), skillsContent, 0644); err != nil { + if err := os.WriteFile(filepath.Join(skillsDir, "SKILL.md"), skillsContent, 0644); err != nil { t.Fatalf("WriteFile: %v", err) } @@ -51,7 +51,7 @@ func TestIntegration_BuildWithSkillsAndEgress(t *testing.T) { Version: "1.0.0", Framework: "custom", Entrypoint: "python main.py", - Skills: types.SkillsRef{Path: filepath.Join(skillsDir, "skills.md")}, + Skills: types.SkillsRef{Path: filepath.Join(skillsDir, "SKILL.md")}, Egress: types.EgressRef{ Profile: "standard", Mode: "allowlist", diff --git a/forge-cli/build/requirements_stage.go b/forge-cli/build/requirements_stage.go index d4607e6..35acba3 100644 --- a/forge-cli/build/requirements_stage.go +++ b/forge-cli/build/requirements_stage.go @@ -5,7 +5,9 @@ import ( "github.com/initializ/forge/forge-core/agentspec" "github.com/initializ/forge/forge-core/pipeline" - coreskills "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-skills/contract" + "github.com/initializ/forge/forge-skills/requirements" + "github.com/initializ/forge/forge-skills/resolver" ) // RequirementsStage validates skill requirements and populates the agent spec. @@ -18,13 +20,13 @@ func (s *RequirementsStage) Execute(ctx context.Context, bc *pipeline.BuildConte return nil } - reqs, ok := bc.SkillRequirements.(*coreskills.AggregatedRequirements) + reqs, ok := bc.SkillRequirements.(*contract.AggregatedRequirements) if !ok { return nil } // Check binaries โ€” warnings only (may be installed in container) - binDiags := coreskills.BinDiagnostics(reqs.Bins) + binDiags := resolver.BinDiagnostics(reqs.Bins) for _, d := range binDiags { bc.AddWarning(d.Message) } @@ -38,7 +40,7 @@ func (s *RequirementsStage) Execute(ctx context.Context, bc *pipeline.BuildConte } // Auto-derive cli_execute config - derived := coreskills.DeriveCLIConfig(reqs) + derived := requirements.DeriveCLIConfig(reqs) if derived != nil && len(derived.AllowedBinaries) > 0 { // Find existing cli_execute tool in spec and merge found := false diff --git a/forge-cli/build/requirements_stage_test.go b/forge-cli/build/requirements_stage_test.go index 65e9ddb..7e1cee3 100644 --- a/forge-cli/build/requirements_stage_test.go +++ b/forge-cli/build/requirements_stage_test.go @@ -6,7 +6,7 @@ import ( "github.com/initializ/forge/forge-core/agentspec" "github.com/initializ/forge/forge-core/pipeline" - coreskills "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-skills/contract" ) func TestRequirementsStage_NoSkills(t *testing.T) { @@ -28,7 +28,7 @@ func TestRequirementsStage_NoSkills(t *testing.T) { func TestRequirementsStage_PopulatesSpec(t *testing.T) { bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) bc.Spec = &agentspec.AgentSpec{} - bc.SkillRequirements = &coreskills.AggregatedRequirements{ + bc.SkillRequirements = &contract.AggregatedRequirements{ Bins: []string{"curl", "jq"}, EnvRequired: []string{"API_KEY"}, EnvOptional: []string{"DEBUG"}, diff --git a/forge-cli/build/security_stage.go b/forge-cli/build/security_stage.go new file mode 100644 index 0000000..7819122 --- /dev/null +++ b/forge-cli/build/security_stage.go @@ -0,0 +1,73 @@ +package build + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-skills/analyzer" + "github.com/initializ/forge/forge-skills/contract" +) + +// SecurityAnalysisStage runs security risk analysis and policy checks on skills. +type SecurityAnalysisStage struct{} + +func (s *SecurityAnalysisStage) Name() string { return "security-analysis" } + +func (s *SecurityAnalysisStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + // Skip if no skills were parsed + if bc.SkillEntries == nil { + return nil + } + + entries, ok := bc.SkillEntries.([]contract.SkillEntry) + if !ok || len(entries) == 0 { + return nil + } + + // Build a hasScript checker from the filesystem + skillsDir := filepath.Join(bc.Opts.WorkDir, "skills") + hasScript := func(name string) bool { + scriptPath := filepath.Join(skillsDir, "scripts", name+".sh") + _, err := os.Stat(scriptPath) + return err == nil + } + + policy := analyzer.DefaultPolicy() + report := analyzer.GenerateReportFromEntries(entries, hasScript, policy) + bc.SecurityAudit = report + + // Write audit artifact + auditJSON, err := analyzer.FormatJSON(report) + if err != nil { + return fmt.Errorf("formatting security audit: %w", err) + } + + auditDir := filepath.Join(bc.Opts.OutputDir, "compiled") + if err := os.MkdirAll(auditDir, 0755); err != nil { + return fmt.Errorf("creating audit directory: %w", err) + } + + auditPath := filepath.Join(auditDir, "security-audit.json") + if err := os.WriteFile(auditPath, auditJSON, 0644); err != nil { + return fmt.Errorf("writing security audit: %w", err) + } + bc.AddFile("compiled/security-audit.json", auditPath) + + // Add warnings for policy violations + for _, a := range report.Assessments { + for _, v := range a.Violations { + bc.AddWarning(fmt.Sprintf("[%s] %s: %s", a.SkillName, v.Rule, v.Message)) + } + } + + // Block build if policy has errors + if !report.PolicySummary.Passed { + return fmt.Errorf("security policy check failed: %d error(s), %d warning(s)", + report.PolicySummary.Errors, report.PolicySummary.Warnings) + } + + return nil +} diff --git a/forge-cli/build/security_stage_test.go b/forge-cli/build/security_stage_test.go new file mode 100644 index 0000000..6466036 --- /dev/null +++ b/forge-cli/build/security_stage_test.go @@ -0,0 +1,99 @@ +package build + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-skills/contract" +) + +func TestSecurityAnalysisStage_Name(t *testing.T) { + s := &SecurityAnalysisStage{} + if s.Name() != "security-analysis" { + t.Fatalf("expected name 'security-analysis', got %q", s.Name()) + } +} + +func TestSecurityAnalysisStage_SkipNoSkills(t *testing.T) { + s := &SecurityAnalysisStage{} + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) + + err := s.Execute(context.Background(), bc) + if err != nil { + t.Fatalf("expected no error when no skills, got: %v", err) + } +} + +func TestSecurityAnalysisStage_CleanSkills(t *testing.T) { + tmpDir := t.TempDir() + outDir := filepath.Join(tmpDir, "output") + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatal(err) + } + + s := &SecurityAnalysisStage{} + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: tmpDir, + OutputDir: outDir, + }) + bc.SkillEntries = []contract.SkillEntry{ + { + Name: "simple-tool", + ForgeReqs: &contract.SkillRequirements{ + Bins: []string{"curl"}, + Env: &contract.EnvRequirements{Required: []string{"API_KEY"}}, + }, + }, + } + + err := s.Execute(context.Background(), bc) + if err != nil { + t.Fatalf("expected no error for clean skills, got: %v", err) + } + + // Check artifact was written + auditPath := filepath.Join(outDir, "compiled", "security-audit.json") + if _, statErr := os.Stat(auditPath); os.IsNotExist(statErr) { + t.Fatal("security-audit.json not written") + } + + // Check it was recorded in generated files + if _, ok := bc.GeneratedFiles["compiled/security-audit.json"]; !ok { + t.Fatal("audit file not recorded in generated files") + } + + // Check SecurityAudit was set + if bc.SecurityAudit == nil { + t.Fatal("SecurityAudit not set in build context") + } +} + +func TestSecurityAnalysisStage_PolicyFail(t *testing.T) { + tmpDir := t.TempDir() + outDir := filepath.Join(tmpDir, "output") + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatal(err) + } + + s := &SecurityAnalysisStage{} + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: tmpDir, + OutputDir: outDir, + }) + bc.SkillEntries = []contract.SkillEntry{ + { + Name: "danger-tool", + ForgeReqs: &contract.SkillRequirements{ + Bins: []string{"nc"}, // denied by default policy + }, + }, + } + + err := s.Execute(context.Background(), bc) + if err == nil { + t.Fatal("expected error for policy-violating skills") + } +} diff --git a/forge-cli/build/skills_stage.go b/forge-cli/build/skills_stage.go index 5a024a0..406bfa7 100644 --- a/forge-cli/build/skills_stage.go +++ b/forge-cli/build/skills_stage.go @@ -8,10 +8,11 @@ import ( cliskills "github.com/initializ/forge/forge-cli/skills" "github.com/initializ/forge/forge-core/pipeline" - coreskills "github.com/initializ/forge/forge-core/skills" + skillcompiler "github.com/initializ/forge/forge-skills/compiler" + "github.com/initializ/forge/forge-skills/requirements" ) -// SkillsStage compiles skills.md into container artifacts. +// SkillsStage compiles SKILL.md into container artifacts. type SkillsStage struct{} func (s *SkillsStage) Name() string { return "compile-skills" } @@ -20,7 +21,7 @@ func (s *SkillsStage) Execute(ctx context.Context, bc *pipeline.BuildContext) er // Determine skills file path skillsPath := bc.Config.Skills.Path if skillsPath == "" { - skillsPath = "skills.md" + skillsPath = "SKILL.md" } if !filepath.IsAbs(skillsPath) { skillsPath = filepath.Join(bc.Opts.WorkDir, skillsPath) @@ -40,13 +41,16 @@ func (s *SkillsStage) Execute(ctx context.Context, bc *pipeline.BuildContext) er return nil } + // Store entries for downstream stages (e.g. security analysis) + bc.SkillEntries = entries + // Aggregate skill requirements and store in build context - reqs := coreskills.AggregateRequirements(entries) + reqs := requirements.AggregateRequirements(entries) if len(reqs.Bins) > 0 || len(reqs.EnvRequired) > 0 || len(reqs.EnvOneOf) > 0 || len(reqs.EnvOptional) > 0 { bc.SkillRequirements = reqs } - compiled, err := coreskills.Compile(entries) + compiled, err := skillcompiler.Compile(entries) if err != nil { return fmt.Errorf("compiling skills: %w", err) } diff --git a/forge-cli/build/skills_stage_test.go b/forge-cli/build/skills_stage_test.go index 02251ba..a40ab6f 100644 --- a/forge-cli/build/skills_stage_test.go +++ b/forge-cli/build/skills_stage_test.go @@ -29,7 +29,7 @@ func TestSkillsStage_NoFile(t *testing.T) { func TestSkillsStage_WithSkills(t *testing.T) { tmpDir := t.TempDir() - // Create a skills.md + // Create a SKILL.md skillsContent := `## Tool: web_search Search the web for information. **Input:** query: string @@ -38,7 +38,7 @@ Search the web for information. ## Tool: summarize Summarize text content. ` - skillsPath := filepath.Join(tmpDir, "skills.md") + skillsPath := filepath.Join(tmpDir, "SKILL.md") if err := os.WriteFile(skillsPath, []byte(skillsContent), 0644); err != nil { t.Fatalf("writing skills.md: %v", err) } diff --git a/forge-cli/cmd/build.go b/forge-cli/cmd/build.go index ad44e0c..f5df470 100644 --- a/forge-cli/cmd/build.go +++ b/forge-cli/cmd/build.go @@ -74,6 +74,7 @@ func runBuild(cmd *cobra.Command, args []string) error { &build.ToolsStage{}, &build.ToolFilterStage{}, &build.SkillsStage{}, + &build.SecurityAnalysisStage{}, &build.RequirementsStage{}, &build.PolicyStage{}, &build.EgressStage{}, diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index 65486d1..946259d 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -16,9 +16,10 @@ import ( "github.com/initializ/forge/forge-cli/internal/tui/steps" "github.com/initializ/forge/forge-cli/skills" "github.com/initializ/forge/forge-cli/templates" - skillreg "github.com/initializ/forge/forge-core/registry" "github.com/initializ/forge/forge-core/tools/builtins" "github.com/initializ/forge/forge-core/util" + "github.com/initializ/forge/forge-skills/contract" + "github.com/initializ/forge/forge-skills/local" ) // initOptions holds all the collected options for project scaffolding. @@ -97,7 +98,7 @@ func init() { initCmd.Flags().StringP("language", "l", "", "language: python, typescript, or go (custom only)") initCmd.Flags().StringP("model-provider", "m", "", "model provider: openai, anthropic, gemini, ollama, or custom") initCmd.Flags().StringSlice("channels", nil, "communication channels (e.g., slack,telegram)") - initCmd.Flags().String("from-skills", "", "path to skills.md file to parse for tools") + initCmd.Flags().String("from-skills", "", "path to SKILL.md file to parse for tools") initCmd.Flags().Bool("non-interactive", false, "run without interactive prompts (requires all flags)") initCmd.Flags().StringSlice("tools", nil, "builtin tools to enable (e.g., web_search,http_request)") initCmd.Flags().StringSlice("skills", nil, "registry skills to include (e.g., github,weather)") @@ -179,31 +180,34 @@ func collectInteractive(opts *initOptions) error { // Load skill info for the skills step var skillInfos []steps.SkillInfo - regSkills, err := skillreg.LoadIndex() - if err == nil { - for _, s := range regSkills { - skillInfos = append(skillInfos, steps.SkillInfo{ - Name: s.Name, - DisplayName: s.DisplayName, - Description: s.Description, - RequiredEnv: s.RequiredEnv, - OneOfEnv: s.OneOfEnv, - OptionalEnv: s.OptionalEnv, - RequiredBins: s.RequiredBins, - EgressDomains: s.EgressDomains, - }) + reg, regErr := local.NewEmbeddedRegistry() + if regErr == nil { + regSkills, listErr := reg.List() + if listErr == nil { + for _, s := range regSkills { + skillInfos = append(skillInfos, steps.SkillInfo{ + Name: s.Name, + DisplayName: s.DisplayName, + Description: s.Description, + RequiredEnv: s.RequiredEnv, + OneOfEnv: s.OneOfEnv, + OptionalEnv: s.OptionalEnv, + RequiredBins: s.RequiredBins, + EgressDomains: s.EgressDomains, + }) + } } } // Build the egress derivation callback (avoids circular import) - deriveEgressFn := func(provider string, channels, tools, skills []string, envVars map[string]string) []string { + deriveEgressFn := func(provider string, channels, tools, selectedSkills []string, envVars map[string]string) []string { tmpOpts := &initOptions{ ModelProvider: provider, Channels: channels, BuiltinTools: tools, EnvVars: envVars, } - selectedInfos := lookupSelectedSkills(skills) + selectedInfos := lookupSelectedSkills(selectedSkills) return deriveEgressDomains(tmpOpts, selectedInfos) } @@ -371,17 +375,20 @@ func collectNonInteractive(opts *initOptions) error { // Validate skill names and check requirements if len(opts.Skills) > 0 { - regSkills, err := skillreg.LoadIndex() - if err != nil { - fmt.Printf("Warning: could not load skill registry: %s\n", err) + niReg, niErr := local.NewEmbeddedRegistry() + if niErr != nil { + fmt.Printf("Warning: could not load skill registry: %s\n", niErr) } else { - validNames := make(map[string]bool) - for _, s := range regSkills { - validNames[s.Name] = true - } - for _, name := range opts.Skills { - if !validNames[name] { - fmt.Printf("Warning: unknown skill %q\n", name) + regSkills, listErr := niReg.List() + if listErr == nil { + validNames := make(map[string]bool) + for _, s := range regSkills { + validNames[s.Name] = true + } + for _, name := range opts.Skills { + if !validNames[name] { + fmt.Printf("Warning: unknown skill %q\n", name) + } } } } @@ -408,8 +415,13 @@ func storeProviderEnvVar(opts *initOptions) { // checkSkillRequirements checks binary and env requirements for selected skills. func checkSkillRequirements(opts *initOptions) { + chkReg, chkErr := local.NewEmbeddedRegistry() + if chkErr != nil { + return + } + for _, skillName := range opts.Skills { - info := skillreg.GetSkillByName(skillName) + info := chkReg.Get(skillName) if info == nil { continue } @@ -453,11 +465,15 @@ func checkSkillRequirements(opts *initOptions) { } } -// lookupSelectedSkills returns SkillInfo entries for the selected skill names. -func lookupSelectedSkills(skillNames []string) []skillreg.SkillInfo { - var result []skillreg.SkillInfo +// lookupSelectedSkills returns SkillDescriptor entries for the selected skill names. +func lookupSelectedSkills(skillNames []string) []contract.SkillDescriptor { + reg, err := local.NewEmbeddedRegistry() + if err != nil { + return nil + } + var result []contract.SkillDescriptor for _, name := range skillNames { - info := skillreg.GetSkillByName(name) + info := reg.Get(name) if info != nil { result = append(result, *info) } @@ -535,8 +551,15 @@ func scaffold(opts *initOptions) error { } // Vendor selected registry skills + scfReg, scfErr := local.NewEmbeddedRegistry() + if scfErr != nil { + fmt.Printf("Warning: could not load skill registry: %s\n", scfErr) + } for _, skillName := range opts.Skills { - content, err := skillreg.LoadSkillFile(skillName) + if scfReg == nil { + continue + } + content, err := scfReg.LoadContent(skillName) if err != nil { fmt.Printf("Warning: could not load skill file for %q: %s\n", skillName, err) continue @@ -547,8 +570,8 @@ func scaffold(opts *initOptions) error { } // Vendor script if the skill has one - if skillreg.HasSkillScript(skillName) { - scriptContent, sErr := skillreg.LoadSkillScript(skillName) + if scfReg.HasScript(skillName) { + scriptContent, sErr := scfReg.LoadScript(skillName) if sErr == nil { scriptDir := filepath.Join(dir, "skills", "scripts") _ = os.MkdirAll(scriptDir, 0o755) @@ -614,7 +637,7 @@ func writeEnvFile(dir string, vars []envVarEntry) error { func getFileManifest(opts *initOptions) []fileToRender { files := []fileToRender{ {TemplatePath: "forge.yaml.tmpl", OutputPath: "forge.yaml"}, - {TemplatePath: "skills.md.tmpl", OutputPath: "skills.md"}, + {TemplatePath: "SKILL.md.tmpl", OutputPath: "SKILL.md"}, {TemplatePath: "env.example.tmpl", OutputPath: ".env.example"}, {TemplatePath: "gitignore.tmpl", OutputPath: ".gitignore"}, } @@ -707,14 +730,17 @@ func buildTemplateData(opts *initOptions) templateData { } // Build skill entries for templates - for _, skillName := range opts.Skills { - info := skillreg.GetSkillByName(skillName) - if info != nil { - data.SkillEntries = append(data.SkillEntries, skillTmplData{ - Name: info.Name, - DisplayName: info.DisplayName, - Description: info.Description, - }) + tmplReg, tmplRegErr := local.NewEmbeddedRegistry() + if tmplRegErr == nil { + for _, skillName := range opts.Skills { + info := tmplReg.Get(skillName) + if info != nil { + data.SkillEntries = append(data.SkillEntries, skillTmplData{ + Name: info.Name, + DisplayName: info.DisplayName, + Description: info.Description, + }) + } } } @@ -810,8 +836,12 @@ func buildEnvVars(opts *initOptions) []envVarEntry { for _, v := range vars { written[v.Key] = true } + envReg, envRegErr := local.NewEmbeddedRegistry() for _, skillName := range opts.Skills { - info := skillreg.GetSkillByName(skillName) + if envRegErr != nil { + continue + } + info := envReg.Get(skillName) if info == nil { continue } diff --git a/forge-cli/cmd/init_egress.go b/forge-cli/cmd/init_egress.go index 2255759..48ef33e 100644 --- a/forge-cli/cmd/init_egress.go +++ b/forge-cli/cmd/init_egress.go @@ -3,8 +3,8 @@ package cmd import ( "sort" - skillreg "github.com/initializ/forge/forge-core/registry" "github.com/initializ/forge/forge-core/security" + "github.com/initializ/forge/forge-skills/contract" ) // providerDomains maps model provider names to their API domains. @@ -17,7 +17,7 @@ var providerDomains = map[string]string{ // deriveEgressDomains computes the full set of egress domains needed based on // the provider, channels, builtin tools, and selected registry skills. -func deriveEgressDomains(opts *initOptions, skills []skillreg.SkillInfo) []string { +func deriveEgressDomains(opts *initOptions, skills []contract.SkillDescriptor) []string { seen := make(map[string]bool) var domains []string diff --git a/forge-cli/cmd/init_test.go b/forge-cli/cmd/init_test.go index 3e0fe9c..26db3e1 100644 --- a/forge-cli/cmd/init_test.go +++ b/forge-cli/cmd/init_test.go @@ -239,7 +239,7 @@ func TestGetFileManifestCommonFiles(t *testing.T) { opts := &initOptions{Framework: "custom", Language: "python"} files := getFileManifest(opts) assertContainsTemplate(t, files, "forge.yaml.tmpl") - assertContainsTemplate(t, files, "skills.md.tmpl") + assertContainsTemplate(t, files, "SKILL.md.tmpl") assertContainsTemplate(t, files, "env.example.tmpl") assertContainsTemplate(t, files, "gitignore.tmpl") } @@ -276,7 +276,7 @@ func TestScaffoldIntegration(t *testing.T) { "forge.yaml", "main.go", "tools/example_tool.go", - "skills.md", + "SKILL.md", ".env.example", ".gitignore", } diff --git a/forge-cli/cmd/skills.go b/forge-cli/cmd/skills.go index d651e84..82e4b90 100644 --- a/forge-cli/cmd/skills.go +++ b/forge-cli/cmd/skills.go @@ -2,6 +2,8 @@ package cmd import ( "bufio" + "crypto/ed25519" + "encoding/base64" "fmt" "os" "os/exec" @@ -10,8 +12,11 @@ import ( "github.com/initializ/forge/forge-cli/config" cliskills "github.com/initializ/forge/forge-cli/skills" - skillreg "github.com/initializ/forge/forge-core/registry" - coreskills "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-skills/analyzer" + "github.com/initializ/forge/forge-skills/local" + "github.com/initializ/forge/forge-skills/requirements" + "github.com/initializ/forge/forge-skills/resolver" + "github.com/initializ/forge/forge-skills/trust" "github.com/spf13/cobra" ) @@ -33,16 +38,56 @@ var skillsAddCmd = &cobra.Command{ RunE: runSkillsAdd, } +var skillsAuditCmd = &cobra.Command{ + Use: "audit", + Short: "Run security audit on skills file", + RunE: runSkillsAudit, +} + +var skillsSignCmd = &cobra.Command{ + Use: "sign ", + Short: "Sign a skill file with an Ed25519 key", + Args: cobra.ExactArgs(1), + RunE: runSkillsSign, +} + +var skillsKeygenCmd = &cobra.Command{ + Use: "keygen ", + Short: "Generate an Ed25519 key pair for skill signing", + Args: cobra.ExactArgs(1), + RunE: runSkillsKeygen, +} + +var auditFormat string +var auditEmbedded bool +var auditDir string +var signKeyPath string + func init() { skillsCmd.AddCommand(skillsValidateCmd) skillsCmd.AddCommand(skillsAddCmd) + skillsCmd.AddCommand(skillsAuditCmd) + skillsCmd.AddCommand(skillsSignCmd) + skillsCmd.AddCommand(skillsKeygenCmd) + + skillsAuditCmd.Flags().StringVar(&auditFormat, "format", "text", "Output format: text or json") + skillsAuditCmd.Flags().BoolVar(&auditEmbedded, "embedded", false, "Audit embedded skills from the binary") + skillsAuditCmd.Flags().StringVar(&auditDir, "dir", "", "Audit skills from a directory of SKILL.md subdirectories") + skillsSignCmd.Flags().StringVar(&signKeyPath, "key", "", "Path to Ed25519 private key") + _ = skillsSignCmd.MarkFlagRequired("key") } func runSkillsAdd(cmd *cobra.Command, args []string) error { name := args[0] + // Create embedded registry + reg, err := local.NewEmbeddedRegistry() + if err != nil { + return fmt.Errorf("loading skill registry: %w", err) + } + // Look up skill in registry - info := skillreg.GetSkillByName(name) + info := reg.Get(name) if info == nil { return fmt.Errorf("skill %q not found in registry", name) } @@ -58,7 +103,7 @@ func runSkillsAdd(cmd *cobra.Command, args []string) error { return fmt.Errorf("creating skills directory: %w", err) } - content, err := skillreg.LoadSkillFile(name) + content, err := reg.LoadContent(name) if err != nil { return fmt.Errorf("loading skill file: %w", err) } @@ -70,8 +115,8 @@ func runSkillsAdd(cmd *cobra.Command, args []string) error { fmt.Printf(" Added skill file: skills/%s.md\n", name) // Write script if the skill has one - if skillreg.HasSkillScript(name) { - scriptContent, sErr := skillreg.LoadSkillScript(name) + if reg.HasScript(name) { + scriptContent, sErr := reg.LoadScript(name) if sErr == nil { scriptDir := filepath.Join(skillDir, "scripts") if mkErr := os.MkdirAll(scriptDir, 0o755); mkErr != nil { @@ -139,7 +184,7 @@ func runSkillsAdd(cmd *cobra.Command, args []string) error { func runSkillsValidate(cmd *cobra.Command, args []string) error { // Determine skills file path - skillsPath := "skills.md" + skillsPath := "SKILL.md" cfgPath := cfgFile if !filepath.IsAbs(cfgPath) { @@ -166,14 +211,14 @@ func runSkillsValidate(cmd *cobra.Command, args []string) error { fmt.Printf("Entries: %d\n\n", len(entries)) // Aggregate requirements - reqs := coreskills.AggregateRequirements(entries) + reqs := requirements.AggregateRequirements(entries) hasErrors := false // Check binaries if len(reqs.Bins) > 0 { fmt.Println("Binaries:") - binDiags := coreskills.BinDiagnostics(reqs.Bins) + binDiags := resolver.BinDiagnostics(reqs.Bins) diagMap := make(map[string]string) for _, d := range binDiags { diagMap[d.Var] = d.Level @@ -198,8 +243,8 @@ func runSkillsValidate(cmd *cobra.Command, args []string) error { // Use the runtime's LoadEnvFile indirectly โ€” just check OS env for now } - resolver := coreskills.NewEnvResolver(osEnv, dotEnv, nil) - envDiags := resolver.Resolve(reqs) + envResolver := resolver.NewEnvResolver(osEnv, dotEnv, nil) + envDiags := envResolver.Resolve(reqs) if len(reqs.EnvRequired) > 0 || len(reqs.EnvOneOf) > 0 || len(reqs.EnvOptional) > 0 { fmt.Println("Environment:") @@ -239,3 +284,161 @@ func envFromOS() map[string]string { } return env } + +func runSkillsAudit(cmd *cobra.Command, args []string) error { + policy := analyzer.DefaultPolicy() + var report *analyzer.AuditReport + + switch { + case auditEmbedded: + reg, err := local.NewEmbeddedRegistry() + if err != nil { + return fmt.Errorf("loading embedded registry: %w", err) + } + r, err := analyzer.GenerateReport(reg, policy) + if err != nil { + return fmt.Errorf("generating report: %w", err) + } + report = r + + case auditDir != "": + reg, err := local.NewLocalRegistry(os.DirFS(auditDir)) + if err != nil { + return fmt.Errorf("loading directory registry %q: %w", auditDir, err) + } + r, err := analyzer.GenerateReport(reg, policy) + if err != nil { + return fmt.Errorf("generating report: %w", err) + } + report = r + + default: + // File-based audit (original behavior) + skillsPath := "SKILL.md" + + cfgPath := cfgFile + if !filepath.IsAbs(cfgPath) { + wd, _ := os.Getwd() + cfgPath = filepath.Join(wd, cfgPath) + } + cfg, err := config.LoadForgeConfig(cfgPath) + if err == nil && cfg.Skills.Path != "" { + skillsPath = cfg.Skills.Path + } + + if !filepath.IsAbs(skillsPath) { + wd, _ := os.Getwd() + skillsPath = filepath.Join(wd, skillsPath) + } + + // Parse with metadata + entries, _, parseErr := cliskills.ParseFileWithMetadata(skillsPath) + if parseErr != nil { + return fmt.Errorf("parsing skills file: %w", parseErr) + } + + // Build hasScript checker from filesystem + skillsDir := filepath.Dir(skillsPath) + hasScript := func(name string) bool { + scriptPath := filepath.Join(skillsDir, "scripts", name+".sh") + _, statErr := os.Stat(scriptPath) + return statErr == nil + } + + report = analyzer.GenerateReportFromEntries(entries, hasScript, policy) + } + + switch auditFormat { + case "json": + data, jsonErr := analyzer.FormatJSON(report) + if jsonErr != nil { + return fmt.Errorf("formatting JSON report: %w", jsonErr) + } + fmt.Println(string(data)) + default: + fmt.Print(analyzer.FormatText(report)) + } + + if !report.PolicySummary.Passed { + return fmt.Errorf("security policy check failed: %d error(s)", report.PolicySummary.Errors) + } + return nil +} + +func runSkillsSign(cmd *cobra.Command, args []string) error { + skillFile := args[0] + + // Read skill content + content, err := os.ReadFile(skillFile) + if err != nil { + return fmt.Errorf("reading skill file: %w", err) + } + + // Read private key + keyData, err := os.ReadFile(signKeyPath) + if err != nil { + return fmt.Errorf("reading private key: %w", err) + } + + privBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(keyData))) + if err != nil { + return fmt.Errorf("decoding private key: %w", err) + } + + if len(privBytes) != ed25519.PrivateKeySize { + return fmt.Errorf("invalid private key size: %d (expected %d)", len(privBytes), ed25519.PrivateKeySize) + } + + privateKey := ed25519.PrivateKey(privBytes) + sig, err := trust.SignSkill(content, privateKey) + if err != nil { + return fmt.Errorf("signing skill: %w", err) + } + + // Write detached signature + sigPath := skillFile + ".sig" + sigB64 := base64.StdEncoding.EncodeToString(sig) + if err := os.WriteFile(sigPath, []byte(sigB64+"\n"), 0644); err != nil { + return fmt.Errorf("writing signature: %w", err) + } + + fmt.Printf("Signature written to %s\n", sigPath) + return nil +} + +func runSkillsKeygen(cmd *cobra.Command, args []string) error { + keyName := args[0] + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("getting home directory: %w", err) + } + + keysDir := filepath.Join(home, ".forge", "keys") + if err := os.MkdirAll(keysDir, 0700); err != nil { + return fmt.Errorf("creating keys directory: %w", err) + } + + pub, priv, err := trust.GenerateKeyPair() + if err != nil { + return fmt.Errorf("generating key pair: %w", err) + } + + // Write private key + privPath := filepath.Join(keysDir, keyName+".key") + privB64 := base64.StdEncoding.EncodeToString(priv) + if err := os.WriteFile(privPath, []byte(privB64+"\n"), 0600); err != nil { + return fmt.Errorf("writing private key: %w", err) + } + + // Write public key + pubPath := filepath.Join(keysDir, keyName+".pub") + pubB64 := base64.StdEncoding.EncodeToString(pub) + if err := os.WriteFile(pubPath, []byte(pubB64+"\n"), 0644); err != nil { + return fmt.Errorf("writing public key: %w", err) + } + + fmt.Printf("Key pair generated:\n Private: %s\n Public: %s\n", privPath, pubPath) + fmt.Printf("\nTo trust this key for signature verification, copy %s to ~/.forge/trusted-keys/\n", filepath.Base(pubPath)) + return nil +} diff --git a/forge-cli/go.mod b/forge-cli/go.mod index 79b5582..90c2d46 100644 --- a/forge-cli/go.mod +++ b/forge-cli/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/initializ/forge/forge-core v0.0.0 github.com/initializ/forge/forge-plugins v0.0.0 + github.com/initializ/forge/forge-skills v0.0.0 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 @@ -45,4 +46,5 @@ require ( replace ( github.com/initializ/forge/forge-core => ../forge-core github.com/initializ/forge/forge-plugins => ../forge-plugins + github.com/initializ/forge/forge-skills => ../forge-skills ) diff --git a/forge-cli/internal/tui/steps/skills_step.go b/forge-cli/internal/tui/steps/skills_step.go index 8d8fae0..038aac9 100644 --- a/forge-cli/internal/tui/steps/skills_step.go +++ b/forge-cli/internal/tui/steps/skills_step.go @@ -402,7 +402,6 @@ func (s *SkillsStep) Apply(ctx *tui.WizardContext) { func skillIcon(name string) string { icons := map[string]string{ - "summarize": "๐Ÿงพ", "github": "๐Ÿ™", "weather": "๐ŸŒค๏ธ", "tavily-search": "๐Ÿ”", diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index acc03e5..338b643 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -16,10 +16,11 @@ import ( "github.com/initializ/forge/forge-core/agentspec" "github.com/initializ/forge/forge-core/llm/providers" coreruntime "github.com/initializ/forge/forge-core/runtime" - coreskills "github.com/initializ/forge/forge-core/skills" "github.com/initializ/forge/forge-core/tools" "github.com/initializ/forge/forge-core/tools/builtins" "github.com/initializ/forge/forge-core/types" + "github.com/initializ/forge/forge-skills/requirements" + "github.com/initializ/forge/forge-skills/resolver" ) // RunnerConfig holds configuration for the Runner. @@ -557,7 +558,7 @@ func (r *Runner) printBanner() { // It also auto-derives cli_execute config from skill requirements. func (r *Runner) validateSkillRequirements(envVars map[string]string) error { // Resolve skills file path - skillsPath := "skills.md" + skillsPath := "SKILL.md" if r.cfg.Config.Skills.Path != "" { skillsPath = r.cfg.Config.Skills.Path } @@ -576,23 +577,23 @@ func (r *Runner) validateSkillRequirements(envVars map[string]string) error { return nil } - reqs := coreskills.AggregateRequirements(entries) + reqs := requirements.AggregateRequirements(entries) if len(reqs.Bins) == 0 && len(reqs.EnvRequired) == 0 && len(reqs.EnvOneOf) == 0 && len(reqs.EnvOptional) == 0 { return nil } // Build env resolver osEnv := envFromOS() - resolver := coreskills.NewEnvResolver(osEnv, envVars, nil) + envResolver := resolver.NewEnvResolver(osEnv, envVars, nil) // Check binaries - binDiags := coreskills.BinDiagnostics(reqs.Bins) + binDiags := resolver.BinDiagnostics(reqs.Bins) for _, d := range binDiags { r.logger.Warn(d.Message, nil) } // Check env vars - envDiags := resolver.Resolve(reqs) + envDiags := envResolver.Resolve(reqs) for _, d := range envDiags { switch d.Level { case "error": @@ -603,7 +604,7 @@ func (r *Runner) validateSkillRequirements(envVars map[string]string) error { } // Auto-derive cli_execute config from skill requirements - derived := coreskills.DeriveCLIConfig(reqs) + derived := requirements.DeriveCLIConfig(reqs) if derived != nil && len(derived.AllowedBinaries) > 0 { // Check if cli_execute is already explicitly configured hasExplicit := false diff --git a/forge-cli/skills/loader.go b/forge-cli/skills/loader.go index d2fb649..543c51f 100644 --- a/forge-cli/skills/loader.go +++ b/forge-cli/skills/loader.go @@ -3,25 +3,26 @@ package skills import ( "os" - coreskills "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-skills/contract" + "github.com/initializ/forge/forge-skills/parser" ) -// ParseFile reads a skills.md file and extracts structured SkillEntry values. -func ParseFile(path string) ([]coreskills.SkillEntry, error) { +// ParseFile reads a SKILL.md file and extracts structured SkillEntry values. +func ParseFile(path string) ([]contract.SkillEntry, error) { f, err := os.Open(path) if err != nil { return nil, err } defer func() { _ = f.Close() }() - return coreskills.Parse(f) + return parser.Parse(f) } -// ParseFileWithMetadata reads a skills.md file and extracts entries with frontmatter metadata. -func ParseFileWithMetadata(path string) ([]coreskills.SkillEntry, *coreskills.SkillMetadata, error) { +// ParseFileWithMetadata reads a SKILL.md file and extracts entries with frontmatter metadata. +func ParseFileWithMetadata(path string) ([]contract.SkillEntry, *contract.SkillMetadata, error) { f, err := os.Open(path) if err != nil { return nil, nil, err } defer func() { _ = f.Close() }() - return coreskills.ParseWithMetadata(f) + return parser.ParseWithMetadata(f) } diff --git a/forge-cli/skills/writer.go b/forge-cli/skills/writer.go index e2155a0..bce38a7 100644 --- a/forge-cli/skills/writer.go +++ b/forge-cli/skills/writer.go @@ -6,11 +6,11 @@ import ( "os" "path/filepath" - coreskills "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-skills/contract" ) // WriteArtifacts creates compiled/skills/skills.json and compiled/prompt.txt in outputDir. -func WriteArtifacts(outputDir string, cs *coreskills.CompiledSkills) error { +func WriteArtifacts(outputDir string, cs *contract.CompiledSkills) error { skillsDir := filepath.Join(outputDir, "compiled", "skills") if err := os.MkdirAll(skillsDir, 0755); err != nil { return fmt.Errorf("creating skills directory: %w", err) diff --git a/forge-cli/templates/init/skills.md.tmpl b/forge-cli/templates/init/SKILL.md.tmpl similarity index 100% rename from forge-cli/templates/init/skills.md.tmpl rename to forge-cli/templates/init/SKILL.md.tmpl diff --git a/forge-cli/templates/init/forge.yaml.tmpl b/forge-cli/templates/init/forge.yaml.tmpl index b85e739..d672e13 100644 --- a/forge-cli/templates/init/forge.yaml.tmpl +++ b/forge-cli/templates/init/forge.yaml.tmpl @@ -32,7 +32,7 @@ builtin_tools: {{- if .SkillEntries}} skills: - path: skills.md + path: SKILL.md {{- end}} {{- if .EgressDomains}} diff --git a/forge-core/forgecore.go b/forge-core/forgecore.go index 4cdd4c2..38a4856 100644 --- a/forge-core/forgecore.go +++ b/forge-core/forgecore.go @@ -12,9 +12,10 @@ import ( "github.com/initializ/forge/forge-core/plugins" "github.com/initializ/forge/forge-core/runtime" "github.com/initializ/forge/forge-core/security" - "github.com/initializ/forge/forge-core/skills" "github.com/initializ/forge/forge-core/types" "github.com/initializ/forge/forge-core/validate" + skillcompiler "github.com/initializ/forge/forge-skills/compiler" + "github.com/initializ/forge/forge-skills/contract" ) // โ”€โ”€โ”€ Compile API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -22,14 +23,14 @@ import ( // CompileRequest contains the inputs for compiling a ForgeConfig into an AgentSpec. type CompileRequest struct { Config *types.ForgeConfig - PluginConfig *plugins.AgentConfig // optional framework plugin config - SkillEntries []skills.SkillEntry // optional skill entries + PluginConfig *plugins.AgentConfig // optional framework plugin config + SkillEntries []contract.SkillEntry // optional skill entries } // CompileResult contains the outputs of a successful compilation. type CompileResult struct { Spec *agentspec.AgentSpec - CompiledSkills *skills.CompiledSkills // nil if no skills + CompiledSkills *contract.CompiledSkills // nil if no skills EgressConfig *security.EgressConfig Allowlist []byte // JSON-encoded allowlist } @@ -45,10 +46,10 @@ func Compile(req CompileRequest) (*CompileResult, error) { } // Compile skills if provided - var cs *skills.CompiledSkills + var cs *contract.CompiledSkills if len(req.SkillEntries) > 0 { var err error - cs, err = skills.Compile(req.SkillEntries) + cs, err = skillcompiler.Compile(req.SkillEntries) if err != nil { return nil, err } diff --git a/forge-core/forgecore_test.go b/forge-core/forgecore_test.go index cfc92d0..764da00 100644 --- a/forge-core/forgecore_test.go +++ b/forge-core/forgecore_test.go @@ -9,8 +9,8 @@ import ( "github.com/initializ/forge/forge-core/agentspec" "github.com/initializ/forge/forge-core/llm" "github.com/initializ/forge/forge-core/runtime" - "github.com/initializ/forge/forge-core/skills" "github.com/initializ/forge/forge-core/types" + "github.com/initializ/forge/forge-skills/contract" ) // โ”€โ”€โ”€ Mock LLM Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -175,7 +175,7 @@ func TestCompile_WithSkills(t *testing.T) { }, } - entries := []skills.SkillEntry{ + entries := []contract.SkillEntry{ { Name: "summarize", Description: "Summarize text content", @@ -1170,7 +1170,7 @@ func TestIntegration_CompileValidateRuntime(t *testing.T) { // 3. Compile compileResult, err := Compile(CompileRequest{ Config: cfg, - SkillEntries: []skills.SkillEntry{ + SkillEntries: []contract.SkillEntry{ {Name: "summarize", Description: "Summarize content"}, }, }) diff --git a/forge-core/go.mod b/forge-core/go.mod index 18e3efd..db9f0fb 100644 --- a/forge-core/go.mod +++ b/forge-core/go.mod @@ -3,6 +3,7 @@ module github.com/initializ/forge/forge-core go 1.25.0 require ( + github.com/initializ/forge/forge-skills v0.0.0 github.com/xeipuuv/gojsonschema v1.2.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -11,3 +12,5 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect ) + +replace github.com/initializ/forge/forge-skills => ../forge-skills diff --git a/forge-core/go.sum b/forge-core/go.sum index d1ac988..f7cd70a 100644 --- a/forge-core/go.sum +++ b/forge-core/go.sum @@ -1,23 +1,7 @@ -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= -github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -27,9 +11,6 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/forge-core/pipeline/context.go b/forge-core/pipeline/context.go index 42a6b8d..c855837 100644 --- a/forge-core/pipeline/context.go +++ b/forge-core/pipeline/context.go @@ -22,6 +22,8 @@ type BuildContext struct { ProdMode bool EgressResolved any // *egress.EgressConfig (avoid import cycle) SkillRequirements any // *skills.AggregatedRequirements (avoid import cycle) + SkillEntries any // []contract.SkillEntry (avoid import cycle) + SecurityAudit any // *analyzer.AuditReport (avoid import cycle) SkillsCount int ToolCategoryCounts map[string]int } diff --git a/forge-core/registry/index.json b/forge-core/registry/index.json deleted file mode 100644 index 6b00870..0000000 --- a/forge-core/registry/index.json +++ /dev/null @@ -1,39 +0,0 @@ -[ - { - "name": "summarize", - "display_name": "Summarize", - "description": "Summarize text or URLs using LLM", - "skill_file": "summarize.md", - "required_env": [], - "one_of_env": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY"], - "optional_env": ["FIRECRAWL_API_KEY", "APIFY_API_TOKEN"], - "required_bins": ["summarize"], - "egress_domains": [] - }, - { - "name": "github", - "display_name": "GitHub", - "description": "Create issues, PRs, and query repositories", - "skill_file": "github.md", - "required_env": ["GH_TOKEN"], - "required_bins": ["gh"], - "egress_domains": ["api.github.com", "github.com"] - }, - { - "name": "weather", - "display_name": "Weather", - "description": "Get current weather and forecasts", - "skill_file": "weather.md", - "required_bins": ["curl"], - "egress_domains": ["api.openweathermap.org", "api.weatherapi.com"] - }, - { - "name": "tavily-search", - "display_name": "Tavily Search", - "description": "Search the web using Tavily AI search API", - "skill_file": "tavily-search.md", - "required_env": ["TAVILY_API_KEY"], - "required_bins": ["curl", "jq"], - "egress_domains": ["api.tavily.com"] - } -] diff --git a/forge-core/registry/registry.go b/forge-core/registry/registry.go deleted file mode 100644 index 98d3ebd..0000000 --- a/forge-core/registry/registry.go +++ /dev/null @@ -1,69 +0,0 @@ -// Package registry provides an embedded skill registry for the forge init wizard. -// Skills are embedded at compile time and can be vendored into new projects. -package registry - -import ( - "embed" - "encoding/json" -) - -//go:embed skills -var skillFS embed.FS - -//go:embed scripts -var scriptFS embed.FS - -//go:embed index.json -var indexJSON []byte - -// SkillInfo describes a skill available in the embedded registry. -type SkillInfo struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - SkillFile string `json:"skill_file"` - RequiredEnv []string `json:"required_env,omitempty"` - OneOfEnv []string `json:"one_of_env,omitempty"` - OptionalEnv []string `json:"optional_env,omitempty"` - RequiredBins []string `json:"required_bins,omitempty"` - EgressDomains []string `json:"egress_domains,omitempty"` -} - -// LoadIndex parses the embedded index.json and returns all registered skills. -func LoadIndex() ([]SkillInfo, error) { - var skills []SkillInfo - if err := json.Unmarshal(indexJSON, &skills); err != nil { - return nil, err - } - return skills, nil -} - -// LoadSkillFile reads the embedded markdown file for the given skill name. -func LoadSkillFile(name string) ([]byte, error) { - return skillFS.ReadFile("skills/" + name + ".md") -} - -// GetSkillByName returns the SkillInfo for a given skill name, or nil if not found. -func GetSkillByName(name string) *SkillInfo { - skills, err := LoadIndex() - if err != nil { - return nil - } - for i := range skills { - if skills[i].Name == name { - return &skills[i] - } - } - return nil -} - -// LoadSkillScript reads an embedded script for a skill. -func LoadSkillScript(name string) ([]byte, error) { - return scriptFS.ReadFile("scripts/" + name + ".sh") -} - -// HasSkillScript checks if a skill has an embedded script. -func HasSkillScript(name string) bool { - _, err := scriptFS.ReadFile("scripts/" + name + ".sh") - return err == nil -} diff --git a/forge-core/registry/registry_test.go b/forge-core/registry/registry_test.go deleted file mode 100644 index 811ea41..0000000 --- a/forge-core/registry/registry_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package registry - -import ( - "strings" - "testing" -) - -func TestLoadIndex(t *testing.T) { - skills, err := LoadIndex() - if err != nil { - t.Fatalf("LoadIndex() error: %v", err) - } - if len(skills) == 0 { - t.Fatal("LoadIndex() returned empty list") - } - - // Verify expected entries exist - names := make(map[string]bool) - for _, s := range skills { - names[s.Name] = true - if s.DisplayName == "" { - t.Errorf("skill %q has empty display_name", s.Name) - } - if s.Description == "" { - t.Errorf("skill %q has empty description", s.Name) - } - if s.SkillFile == "" { - t.Errorf("skill %q has empty skill_file", s.Name) - } - } - - for _, expected := range []string{"summarize", "github", "weather", "tavily-search"} { - if !names[expected] { - t.Errorf("expected skill %q not found in index", expected) - } - } -} - -func TestLoadSkillFile(t *testing.T) { - skills, err := LoadIndex() - if err != nil { - t.Fatalf("LoadIndex() error: %v", err) - } - - for _, s := range skills { - data, err := LoadSkillFile(s.Name) - if err != nil { - t.Errorf("LoadSkillFile(%q) error: %v", s.Name, err) - continue - } - if len(data) == 0 { - t.Errorf("LoadSkillFile(%q) returned empty content", s.Name) - } - // Verify it's valid markdown with at least one tool heading - content := string(data) - if !strings.Contains(content, "## Tool:") { - t.Errorf("LoadSkillFile(%q) missing '## Tool:' heading", s.Name) - } - } -} - -func TestGetSkillByName(t *testing.T) { - s := GetSkillByName("github") - if s == nil { - t.Fatal("GetSkillByName(\"github\") returned nil") - } - if s.DisplayName != "GitHub" { - t.Errorf("expected display_name \"GitHub\", got %q", s.DisplayName) - } - - if GetSkillByName("nonexistent") != nil { - t.Error("GetSkillByName(\"nonexistent\") should return nil") - } -} - -func TestGitHubSkillRequirements(t *testing.T) { - s := GetSkillByName("github") - if s == nil { - t.Fatal("github skill not found") - } - if len(s.RequiredEnv) == 0 { - t.Error("github skill should have required_env") - } - if len(s.RequiredBins) == 0 { - t.Error("github skill should have required_bins") - } - if len(s.EgressDomains) == 0 { - t.Error("github skill should have egress_domains") - } -} - -func TestWeatherSkillRequiredBins(t *testing.T) { - s := GetSkillByName("weather") - if s == nil { - t.Fatal("weather skill not found") - } - if len(s.RequiredBins) == 0 { - t.Error("weather skill should have required_bins") - } - found := false - for _, b := range s.RequiredBins { - if b == "curl" { - found = true - } - } - if !found { - t.Error("weather skill should require curl binary") - } -} - -func TestTavilySearchSkillRequirements(t *testing.T) { - s := GetSkillByName("tavily-search") - if s == nil { - t.Fatal("tavily-search skill not found") - } - if s.DisplayName != "Tavily Search" { - t.Errorf("expected display_name \"Tavily Search\", got %q", s.DisplayName) - } - if len(s.RequiredEnv) == 0 { - t.Error("tavily-search skill should have required_env") - } - foundKey := false - for _, env := range s.RequiredEnv { - if env == "TAVILY_API_KEY" { - foundKey = true - } - } - if !foundKey { - t.Error("tavily-search skill should require TAVILY_API_KEY") - } - if len(s.RequiredBins) < 2 { - t.Error("tavily-search skill should require curl and jq") - } - if len(s.EgressDomains) == 0 { - t.Error("tavily-search skill should have egress_domains") - } - foundDomain := false - for _, d := range s.EgressDomains { - if d == "api.tavily.com" { - foundDomain = true - } - } - if !foundDomain { - t.Error("tavily-search skill should have api.tavily.com egress domain") - } -} - -func TestLoadSkillScript(t *testing.T) { - // tavily-search should have a script - if !HasSkillScript("tavily-search") { - t.Fatal("HasSkillScript(\"tavily-search\") returned false") - } - - data, err := LoadSkillScript("tavily-search") - if err != nil { - t.Fatalf("LoadSkillScript(\"tavily-search\") error: %v", err) - } - if len(data) == 0 { - t.Error("LoadSkillScript(\"tavily-search\") returned empty content") - } - if !strings.Contains(string(data), "TAVILY_API_KEY") { - t.Error("tavily-search script should reference TAVILY_API_KEY") - } - - // Skills without scripts should return false - if HasSkillScript("github") { - t.Error("HasSkillScript(\"github\") should return false") - } - if HasSkillScript("nonexistent") { - t.Error("HasSkillScript(\"nonexistent\") should return false") - } -} diff --git a/forge-core/registry/skills/summarize.md b/forge-core/registry/skills/summarize.md deleted file mode 100644 index c3d439c..0000000 --- a/forge-core/registry/skills/summarize.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: summarize -description: Summarize text or URLs using LLM -metadata: - forge: - requires: - bins: - - summarize - env: - required: [] - one_of: - - OPENAI_API_KEY - - ANTHROPIC_API_KEY - - XAI_API_KEY - - GEMINI_API_KEY - optional: - - FIRECRAWL_API_KEY - - APIFY_API_TOKEN ---- -## Tool: summarize_text - -Summarize a block of text into key points. - -**Input:** text (string) - The text to summarize -**Output:** A concise summary of the input text - -## Tool: summarize_url - -Fetch and summarize the content of a URL. - -**Input:** url (string) - The URL to fetch and summarize -**Output:** A concise summary of the page content diff --git a/forge-core/skills/types.go b/forge-core/skills/types.go deleted file mode 100644 index 2bdf90e..0000000 --- a/forge-core/skills/types.go +++ /dev/null @@ -1,37 +0,0 @@ -package skills - -// SkillEntry represents a single tool/skill parsed from a skills.md file. -type SkillEntry struct { - Name string - Description string - InputSpec string - OutputSpec string - Metadata *SkillMetadata // nil if no frontmatter - ForgeReqs *SkillRequirements // convenience: extracted from metadata.forge.requires -} - -// SkillMetadata holds the full frontmatter parsed from YAML between --- delimiters. -// Uses map to tolerate unknown namespaces (e.g. clawdbot:). -type SkillMetadata struct { - Name string `yaml:"name,omitempty"` - Description string `yaml:"description,omitempty"` - Metadata map[string]map[string]any `yaml:"metadata,omitempty"` -} - -// ForgeSkillMeta holds Forge-specific metadata from the "forge" namespace. -type ForgeSkillMeta struct { - Requires *SkillRequirements `yaml:"requires,omitempty" json:"requires,omitempty"` -} - -// SkillRequirements declares CLI binaries and environment variables a skill needs. -type SkillRequirements struct { - Bins []string `yaml:"bins,omitempty" json:"bins,omitempty"` - Env *EnvRequirements `yaml:"env,omitempty" json:"env,omitempty"` -} - -// EnvRequirements declares environment variable requirements at different levels. -type EnvRequirements struct { - Required []string `yaml:"required,omitempty" json:"required,omitempty"` - OneOf []string `yaml:"one_of,omitempty" json:"one_of,omitempty"` - Optional []string `yaml:"optional,omitempty" json:"optional,omitempty"` -} diff --git a/forge-core/types/config.go b/forge-core/types/config.go index d6c6fc9..0770661 100644 --- a/forge-core/types/config.go +++ b/forge-core/types/config.go @@ -31,7 +31,7 @@ type EgressRef struct { // SkillsRef references a skills definition file. type SkillsRef struct { - Path string `yaml:"path,omitempty"` // default: "skills.md" + Path string `yaml:"path,omitempty"` // default: "SKILL.md" } // ModelRef identifies the model an agent uses. diff --git a/forge-skills/analyzer/policy.go b/forge-skills/analyzer/policy.go new file mode 100644 index 0000000..4bf5935 --- /dev/null +++ b/forge-skills/analyzer/policy.go @@ -0,0 +1,138 @@ +package analyzer + +import ( + "fmt" + "strings" + + "github.com/initializ/forge/forge-skills/contract" +) + +// DefaultPolicy returns a SecurityPolicy with sensible defaults. +func DefaultPolicy() SecurityPolicy { + return SecurityPolicy{ + MaxEgressDomains: 0, // unlimited + BinaryDenylist: []string{"nc", "ncat", "netcat", "nmap", "ssh", "scp"}, + DeniedEnvPatterns: []string{ + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + }, + ScriptPolicy: "warn", + MaxRiskScore: 75, + TrustedDomains: nil, + } +} + +// CheckPolicy evaluates a SkillDescriptor against a SecurityPolicy. +func CheckPolicy(sd *contract.SkillDescriptor, hasScript bool, policy SecurityPolicy) []PolicyViolation { + var violations []PolicyViolation + + // Rule 1: MaxEgressDomains + if policy.MaxEgressDomains > 0 && len(sd.EgressDomains) > policy.MaxEgressDomains { + violations = append(violations, PolicyViolation{ + Rule: "max_egress_domains", + Severity: "error", + Message: fmt.Sprintf("skill has %d egress domains (max: %d)", len(sd.EgressDomains), policy.MaxEgressDomains), + }) + } + + // Rule 2: BinaryDenylist + denySet := make(map[string]bool, len(policy.BinaryDenylist)) + for _, b := range policy.BinaryDenylist { + denySet[b] = true + } + for _, bin := range sd.RequiredBins { + if denySet[bin] { + violations = append(violations, PolicyViolation{ + Rule: "binary_denylist", + Severity: "error", + Message: fmt.Sprintf("binary %q is denied by policy", bin), + }) + } + } + + // Rule 3: DeniedEnvPatterns + allEnv := make([]string, 0, len(sd.RequiredEnv)+len(sd.OneOfEnv)+len(sd.OptionalEnv)) + allEnv = append(allEnv, sd.RequiredEnv...) + allEnv = append(allEnv, sd.OneOfEnv...) + allEnv = append(allEnv, sd.OptionalEnv...) + for _, env := range allEnv { + for _, pattern := range policy.DeniedEnvPatterns { + if strings.Contains(strings.ToUpper(env), strings.ToUpper(pattern)) { + violations = append(violations, PolicyViolation{ + Rule: "denied_env_pattern", + Severity: "error", + Message: fmt.Sprintf("env var %q matches denied pattern %q", env, pattern), + }) + } + } + } + + // Rule 4: ScriptPolicy + if hasScript { + switch policy.ScriptPolicy { + case "deny": + violations = append(violations, PolicyViolation{ + Rule: "script_policy", + Severity: "error", + Message: "skill has an executable script (denied by policy)", + }) + case "warn": + violations = append(violations, PolicyViolation{ + Rule: "script_policy", + Severity: "warning", + Message: "skill has an executable script", + }) + } + // "allow" - no violation + } + + // Rule 5: MaxRiskScore + if policy.MaxRiskScore > 0 { + assessment := AnalyzeSkillDescriptor(sd, hasScript) + if assessment.Score.Value > policy.MaxRiskScore { + violations = append(violations, PolicyViolation{ + Rule: "max_risk_score", + Severity: "error", + Message: fmt.Sprintf("risk score %d exceeds maximum %d", assessment.Score.Value, policy.MaxRiskScore), + }) + } + } + + return violations +} + +// CheckPolicyFromEntry evaluates a SkillEntry against a SecurityPolicy. +// It builds a temporary SkillDescriptor from the entry's metadata. +func CheckPolicyFromEntry(entry *contract.SkillEntry, hasScript bool, policy SecurityPolicy) []PolicyViolation { + sd := entryToDescriptor(entry) + return CheckPolicy(sd, hasScript, policy) +} + +// entryToDescriptor converts a SkillEntry to a SkillDescriptor for policy checking. +func entryToDescriptor(entry *contract.SkillEntry) *contract.SkillDescriptor { + sd := &contract.SkillDescriptor{ + Name: entry.Name, + } + if entry.ForgeReqs != nil { + sd.RequiredBins = entry.ForgeReqs.Bins + if entry.ForgeReqs.Env != nil { + sd.RequiredEnv = entry.ForgeReqs.Env.Required + sd.OneOfEnv = entry.ForgeReqs.Env.OneOf + sd.OptionalEnv = entry.ForgeReqs.Env.Optional + } + } + if entry.Metadata != nil && entry.Metadata.Metadata != nil { + if forgeMap, ok := entry.Metadata.Metadata["forge"]; ok { + if raw, ok := forgeMap["egress_domains"]; ok { + if arr, ok := raw.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + sd.EgressDomains = append(sd.EgressDomains, s) + } + } + } + } + } + } + return sd +} diff --git a/forge-skills/analyzer/policy_test.go b/forge-skills/analyzer/policy_test.go new file mode 100644 index 0000000..c53d579 --- /dev/null +++ b/forge-skills/analyzer/policy_test.go @@ -0,0 +1,165 @@ +package analyzer + +import ( + "testing" + + "github.com/initializ/forge/forge-skills/contract" +) + +func TestDefaultPolicy(t *testing.T) { + p := DefaultPolicy() + if p.ScriptPolicy != "warn" { + t.Fatalf("expected script_policy 'warn', got %q", p.ScriptPolicy) + } + if p.MaxRiskScore != 75 { + t.Fatalf("expected max_risk_score 75, got %d", p.MaxRiskScore) + } + if len(p.BinaryDenylist) == 0 { + t.Fatal("expected non-empty binary denylist") + } +} + +func TestCheckPolicy_Clean(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "safe-tool", + RequiredBins: []string{"curl", "jq"}, + RequiredEnv: []string{"API_KEY"}, + } + violations := CheckPolicy(sd, false, DefaultPolicy()) + + for _, v := range violations { + if v.Severity == "error" { + t.Fatalf("unexpected error violation: %s", v.Message) + } + } +} + +func TestCheckPolicy_DeniedBinary(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "hacker-tool", + RequiredBins: []string{"nc"}, + } + violations := CheckPolicy(sd, false, DefaultPolicy()) + + found := false + for _, v := range violations { + if v.Rule == "binary_denylist" && v.Severity == "error" { + found = true + } + } + if !found { + t.Fatal("expected binary_denylist error for nc") + } +} + +func TestCheckPolicy_DeniedEnvPattern(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "aws-tool", + RequiredEnv: []string{"AWS_SECRET_ACCESS_KEY"}, + } + violations := CheckPolicy(sd, false, DefaultPolicy()) + + found := false + for _, v := range violations { + if v.Rule == "denied_env_pattern" && v.Severity == "error" { + found = true + } + } + if !found { + t.Fatal("expected denied_env_pattern error for AWS_SECRET_ACCESS_KEY") + } +} + +func TestCheckPolicy_ScriptPolicyWarn(t *testing.T) { + sd := &contract.SkillDescriptor{Name: "scripted"} + violations := CheckPolicy(sd, true, DefaultPolicy()) + + found := false + for _, v := range violations { + if v.Rule == "script_policy" && v.Severity == "warning" { + found = true + } + } + if !found { + t.Fatal("expected script_policy warning") + } +} + +func TestCheckPolicy_ScriptPolicyDeny(t *testing.T) { + policy := DefaultPolicy() + policy.ScriptPolicy = "deny" + + sd := &contract.SkillDescriptor{Name: "scripted"} + violations := CheckPolicy(sd, true, policy) + + found := false + for _, v := range violations { + if v.Rule == "script_policy" && v.Severity == "error" { + found = true + } + } + if !found { + t.Fatal("expected script_policy error") + } +} + +func TestCheckPolicy_ScriptPolicyAllow(t *testing.T) { + policy := DefaultPolicy() + policy.ScriptPolicy = "allow" + + sd := &contract.SkillDescriptor{Name: "scripted"} + violations := CheckPolicy(sd, true, policy) + + for _, v := range violations { + if v.Rule == "script_policy" { + t.Fatal("should not have script_policy violation with allow") + } + } +} + +func TestCheckPolicy_MaxEgressDomains(t *testing.T) { + policy := DefaultPolicy() + policy.MaxEgressDomains = 2 + + sd := &contract.SkillDescriptor{ + Name: "chatty", + EgressDomains: []string{"a.com", "b.com", "c.com"}, + } + violations := CheckPolicy(sd, false, policy) + + found := false + for _, v := range violations { + if v.Rule == "max_egress_domains" && v.Severity == "error" { + found = true + } + } + if !found { + t.Fatal("expected max_egress_domains error") + } +} + +func TestCheckPolicy_MaxRiskScore(t *testing.T) { + policy := DefaultPolicy() + policy.MaxRiskScore = 10 + // Clear other rules to isolate this test + policy.BinaryDenylist = nil + policy.DeniedEnvPatterns = nil + policy.ScriptPolicy = "allow" + + sd := &contract.SkillDescriptor{ + Name: "risky", + EgressDomains: []string{"unknown.example.com", "evil.example.com"}, + RequiredBins: []string{"bash"}, + } + violations := CheckPolicy(sd, false, policy) + + found := false + for _, v := range violations { + if v.Rule == "max_risk_score" && v.Severity == "error" { + found = true + } + } + if !found { + t.Fatal("expected max_risk_score error") + } +} diff --git a/forge-skills/analyzer/report.go b/forge-skills/analyzer/report.go new file mode 100644 index 0000000..1ae8843 --- /dev/null +++ b/forge-skills/analyzer/report.go @@ -0,0 +1,174 @@ +package analyzer + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/initializ/forge/forge-skills/contract" +) + +// GenerateReport produces a full audit report from a SkillRegistry. +func GenerateReport(registry contract.SkillRegistry, policy SecurityPolicy) (*AuditReport, error) { + skills, err := registry.List() + if err != nil { + return nil, fmt.Errorf("listing skills: %w", err) + } + + entries := make([]contract.SkillEntry, 0, len(skills)) + for _, sd := range skills { + entry := contract.SkillEntry{ + Name: sd.Name, + Description: sd.Description, + } + if len(sd.RequiredBins) > 0 || len(sd.RequiredEnv) > 0 || len(sd.OneOfEnv) > 0 || len(sd.OptionalEnv) > 0 || len(sd.EgressDomains) > 0 { + entry.ForgeReqs = &contract.SkillRequirements{ + Bins: sd.RequiredBins, + } + if len(sd.RequiredEnv) > 0 || len(sd.OneOfEnv) > 0 || len(sd.OptionalEnv) > 0 { + entry.ForgeReqs.Env = &contract.EnvRequirements{ + Required: sd.RequiredEnv, + OneOf: sd.OneOfEnv, + Optional: sd.OptionalEnv, + } + } + if len(sd.EgressDomains) > 0 { + entry.Metadata = &contract.SkillMetadata{ + Metadata: map[string]map[string]any{ + "forge": { + "egress_domains": toAnySlice(sd.EgressDomains), + }, + }, + } + } + } + entries = append(entries, entry) + } + + hasScript := func(name string) bool { + return registry.HasScript(name) + } + + return GenerateReportFromEntries(entries, hasScript, policy), nil +} + +// GenerateReportFromEntries produces an audit report from parsed skill entries. +func GenerateReportFromEntries(entries []contract.SkillEntry, hasScript func(string) bool, policy SecurityPolicy) *AuditReport { + report := &AuditReport{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + SkillCount: len(entries), + Assessments: make([]SkillRiskAssessment, 0, len(entries)), + } + + totalScore := 0 + totalErrors := 0 + totalWarnings := 0 + + for i := range entries { + entry := &entries[i] + hs := hasScript != nil && hasScript(entry.Name) + + assessment := AnalyzeSkillEntry(entry, hs) + + // Run policy checks + violations := CheckPolicyFromEntry(entry, hs, policy) + assessment.Violations = violations + + for _, v := range violations { + switch v.Severity { + case "error": + totalErrors++ + case "warning": + totalWarnings++ + } + } + + totalScore += assessment.Score.Value + report.Assessments = append(report.Assessments, assessment) + } + + // Compute aggregate score as average + avgScore := 0 + if len(entries) > 0 { + avgScore = totalScore / len(entries) + } + report.AggregateScore = RiskScore{ + Value: avgScore, + Level: classifyScore(avgScore), + } + + report.PolicySummary = PolicySummary{ + TotalViolations: totalErrors + totalWarnings, + Errors: totalErrors, + Warnings: totalWarnings, + Passed: totalErrors == 0, + } + + return report +} + +// FormatJSON serializes an AuditReport to indented JSON. +func FormatJSON(report *AuditReport) ([]byte, error) { + return json.MarshalIndent(report, "", " ") +} + +// FormatText produces a human-readable text representation of an AuditReport. +func FormatText(report *AuditReport) string { + var b strings.Builder + + b.WriteString("Security Audit Report\n") + b.WriteString("=====================\n") + fmt.Fprintf(&b, "Skills analyzed: %d\n", report.SkillCount) + + for _, a := range report.Assessments { + b.WriteString("\n") + fmt.Fprintf(&b, "%-28s Risk: %s (%d/100)\n", a.SkillName, a.Score.Level, a.Score.Value) + + if len(a.Factors) > 0 { + b.WriteString(" Factors:\n") + for _, f := range a.Factors { + fmt.Fprintf(&b, " %-8s +%-3d %s\n", f.Category, f.Points, f.Description) + } + } + + if len(a.Violations) > 0 { + b.WriteString(" Violations:\n") + for _, v := range a.Violations { + sev := "WARN " + if v.Severity == "error" { + sev = "ERROR" + } + fmt.Fprintf(&b, " %s %s: %s\n", sev, v.Rule, v.Message) + } + } else { + b.WriteString(" Violations: none\n") + } + + if len(a.Recommendations) > 0 { + b.WriteString(" Recommendations:\n") + for _, r := range a.Recommendations { + fmt.Fprintf(&b, " - %s\n", r) + } + } + } + + b.WriteString("\n") + fmt.Fprintf(&b, "Aggregate Risk: %s (%d/100)\n", report.AggregateScore.Level, report.AggregateScore.Value) + passedStr := "PASSED" + if !report.PolicySummary.Passed { + passedStr = "FAILED" + } + fmt.Fprintf(&b, "Policy Summary: %s (%d errors, %d warnings)\n", + passedStr, report.PolicySummary.Errors, report.PolicySummary.Warnings) + + return b.String() +} + +func toAnySlice(ss []string) []any { + result := make([]any, len(ss)) + for i, s := range ss { + result[i] = s + } + return result +} diff --git a/forge-skills/analyzer/report_test.go b/forge-skills/analyzer/report_test.go new file mode 100644 index 0000000..0e39ca4 --- /dev/null +++ b/forge-skills/analyzer/report_test.go @@ -0,0 +1,152 @@ +package analyzer + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/initializ/forge/forge-skills/contract" +) + +func TestGenerateReportFromEntries(t *testing.T) { + entries := []contract.SkillEntry{ + { + Name: "github", + ForgeReqs: &contract.SkillRequirements{ + Bins: []string{"gh"}, + Env: &contract.EnvRequirements{Required: []string{"GH_TOKEN"}}, + }, + Metadata: &contract.SkillMetadata{ + Metadata: map[string]map[string]any{ + "forge": { + "egress_domains": []any{"api.github.com", "github.com"}, + }, + }, + }, + }, + { + Name: "simple", + }, + } + + hasScript := func(name string) bool { return false } + report := GenerateReportFromEntries(entries, hasScript, DefaultPolicy()) + + if report.SkillCount != 2 { + t.Fatalf("expected 2 skills, got %d", report.SkillCount) + } + if len(report.Assessments) != 2 { + t.Fatalf("expected 2 assessments, got %d", len(report.Assessments)) + } + if report.Assessments[0].SkillName != "github" { + t.Fatalf("expected first skill 'github', got %q", report.Assessments[0].SkillName) + } +} + +func TestGenerateReportFromEntries_PolicyFail(t *testing.T) { + entries := []contract.SkillEntry{ + { + Name: "hacker", + ForgeReqs: &contract.SkillRequirements{ + Bins: []string{"nc"}, + }, + }, + } + + hasScript := func(name string) bool { return false } + report := GenerateReportFromEntries(entries, hasScript, DefaultPolicy()) + + if report.PolicySummary.Passed { + t.Fatal("expected policy to fail") + } + if report.PolicySummary.Errors == 0 { + t.Fatal("expected errors > 0") + } +} + +func TestFormatJSON(t *testing.T) { + entries := []contract.SkillEntry{{Name: "test"}} + report := GenerateReportFromEntries(entries, nil, DefaultPolicy()) + + data, err := FormatJSON(report) + if err != nil { + t.Fatalf("FormatJSON failed: %v", err) + } + + // Verify it's valid JSON + var parsed AuditReport + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if parsed.SkillCount != 1 { + t.Fatalf("expected skill_count 1, got %d", parsed.SkillCount) + } +} + +func TestFormatText(t *testing.T) { + entries := []contract.SkillEntry{ + { + Name: "github", + ForgeReqs: &contract.SkillRequirements{ + Bins: []string{"gh"}, + Env: &contract.EnvRequirements{Required: []string{"GH_TOKEN"}}, + }, + Metadata: &contract.SkillMetadata{ + Metadata: map[string]map[string]any{ + "forge": { + "egress_domains": []any{"api.github.com"}, + }, + }, + }, + }, + } + + hasScript := func(name string) bool { return false } + report := GenerateReportFromEntries(entries, hasScript, DefaultPolicy()) + text := FormatText(report) + + if !strings.Contains(text, "Security Audit Report") { + t.Fatal("missing header") + } + if !strings.Contains(text, "github") { + t.Fatal("missing skill name") + } + if !strings.Contains(text, "Aggregate Risk") { + t.Fatal("missing aggregate risk") + } + if !strings.Contains(text, "Policy Summary") { + t.Fatal("missing policy summary") + } +} + +func TestFormatText_WithViolations(t *testing.T) { + entries := []contract.SkillEntry{ + { + Name: "scripted", + }, + } + + hasScript := func(name string) bool { return name == "scripted" } + report := GenerateReportFromEntries(entries, hasScript, DefaultPolicy()) + text := FormatText(report) + + if !strings.Contains(text, "WARN") { + t.Fatal("expected WARN in output") + } +} + +func TestAggregateScore_Average(t *testing.T) { + entries := []contract.SkillEntry{ + {Name: "a"}, + {Name: "b", ForgeReqs: &contract.SkillRequirements{ + Bins: []string{"bash"}, // 15 points + }}, + } + + report := GenerateReportFromEntries(entries, nil, DefaultPolicy()) + + // Expected: (0 + 15) / 2 = 7 + if report.AggregateScore.Value != 7 { + t.Fatalf("expected aggregate 7, got %d", report.AggregateScore.Value) + } +} diff --git a/forge-skills/analyzer/scoring.go b/forge-skills/analyzer/scoring.go new file mode 100644 index 0000000..34fd9bd --- /dev/null +++ b/forge-skills/analyzer/scoring.go @@ -0,0 +1,271 @@ +package analyzer + +import ( + "fmt" + "strings" + + "github.com/initializ/forge/forge-skills/contract" +) + +// Well-known trusted domains that receive lower risk scores. +var trustedDomains = map[string]bool{ + "api.github.com": true, + "github.com": true, + "api.openai.com": true, + "api.anthropic.com": true, + "api.tavily.com": true, + "api.slack.com": true, + "hooks.slack.com": true, + "api.telegram.org": true, + "googleapis.com": true, + "api.together.ai": true, + "api.cohere.com": true, +} + +// High-risk binaries that may allow arbitrary code execution. +var highRiskBinaries = map[string]bool{ + "bash": true, + "sh": true, + "python": true, + "python3": true, + "node": true, + "ssh": true, + "nc": true, + "ncat": true, + "netcat": true, + "perl": true, + "ruby": true, +} + +// Patterns that indicate sensitive environment variables. +var sensitiveEnvPatterns = []string{ + "SECRET", + "PASSWORD", + "PRIVATE_KEY", + "SESSION_TOKEN", + "CREDENTIALS", +} + +// AnalyzeSkillDescriptor scores a SkillDescriptor for security risk. +func AnalyzeSkillDescriptor(sd *contract.SkillDescriptor, hasScript bool) SkillRiskAssessment { + var factors []RiskFactor + + factors = append(factors, scoreEgress(sd.EgressDomains, nil)...) + factors = append(factors, scoreBinaries(sd.RequiredBins)...) + factors = append(factors, scoreEnv(sd.RequiredEnv, sd.OneOfEnv, sd.OptionalEnv)...) + if hasScript { + factors = append(factors, scoreScript()...) + } + + total := sumPoints(factors) + return SkillRiskAssessment{ + SkillName: sd.Name, + Score: RiskScore{Value: total, Level: classifyScore(total)}, + Factors: factors, + Recommendations: generateRecommendations(factors, hasScript), + } +} + +// AnalyzeSkillEntry scores a SkillEntry for security risk. +func AnalyzeSkillEntry(entry *contract.SkillEntry, hasScript bool) SkillRiskAssessment { + var factors []RiskFactor + var egressDomains []string + var bins []string + var reqEnv, oneOfEnv, optEnv []string + + if entry.ForgeReqs != nil { + bins = entry.ForgeReqs.Bins + if entry.ForgeReqs.Env != nil { + reqEnv = entry.ForgeReqs.Env.Required + oneOfEnv = entry.ForgeReqs.Env.OneOf + optEnv = entry.ForgeReqs.Env.Optional + } + } + if entry.Metadata != nil && entry.Metadata.Metadata != nil { + if forgeMap, ok := entry.Metadata.Metadata["forge"]; ok { + if raw, ok := forgeMap["egress_domains"]; ok { + if arr, ok := raw.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + egressDomains = append(egressDomains, s) + } + } + } + } + } + } + + factors = append(factors, scoreEgress(egressDomains, nil)...) + factors = append(factors, scoreBinaries(bins)...) + factors = append(factors, scoreEnv(reqEnv, oneOfEnv, optEnv)...) + if hasScript { + factors = append(factors, scoreScript()...) + } + + total := sumPoints(factors) + return SkillRiskAssessment{ + SkillName: entry.Name, + Score: RiskScore{Value: total, Level: classifyScore(total)}, + Factors: factors, + Recommendations: generateRecommendations(factors, hasScript), + } +} + +func scoreEgress(domains []string, extraTrusted []string) []RiskFactor { + var factors []RiskFactor + + trusted := make(map[string]bool) + for k, v := range trustedDomains { + trusted[k] = v + } + for _, d := range extraTrusted { + trusted[d] = true + } + + for _, domain := range domains { + if trusted[domain] { + factors = append(factors, RiskFactor{ + Category: "egress", + Description: fmt.Sprintf("trusted domain: %s", domain), + Points: 2, + }) + } else { + factors = append(factors, RiskFactor{ + Category: "egress", + Description: fmt.Sprintf("unknown domain: %s", domain), + Points: 10, + }) + } + } + + if len(domains) > 5 { + factors = append(factors, RiskFactor{ + Category: "egress", + Description: fmt.Sprintf(">5 total domains (%d)", len(domains)), + Points: 15, + }) + } + + return factors +} + +func scoreBinaries(bins []string) []RiskFactor { + var factors []RiskFactor + for _, bin := range bins { + if highRiskBinaries[bin] { + factors = append(factors, RiskFactor{ + Category: "binary", + Description: fmt.Sprintf("high-risk binary: %s", bin), + Points: 15, + }) + } else { + factors = append(factors, RiskFactor{ + Category: "binary", + Description: fmt.Sprintf("standard binary: %s", bin), + Points: 3, + }) + } + } + return factors +} + +func scoreEnv(reqEnv, oneOfEnv, optEnv []string) []RiskFactor { + var factors []RiskFactor + allEnv := make([]string, 0, len(reqEnv)+len(oneOfEnv)+len(optEnv)) + allEnv = append(allEnv, reqEnv...) + allEnv = append(allEnv, oneOfEnv...) + allEnv = append(allEnv, optEnv...) + + for _, env := range allEnv { + if isSensitiveEnv(env) { + factors = append(factors, RiskFactor{ + Category: "env", + Description: fmt.Sprintf("sensitive variable: %s", env), + Points: 10, + }) + } else { + factors = append(factors, RiskFactor{ + Category: "env", + Description: fmt.Sprintf("API key: %s", env), + Points: 5, + }) + } + } + return factors +} + +func scoreScript() []RiskFactor { + return []RiskFactor{{ + Category: "script", + Description: "has executable script", + Points: 20, + }} +} + +func isSensitiveEnv(name string) bool { + upper := strings.ToUpper(name) + for _, pattern := range sensitiveEnvPatterns { + if strings.Contains(upper, pattern) { + return true + } + } + return false +} + +func sumPoints(factors []RiskFactor) int { + total := 0 + for _, f := range factors { + total += f.Points + } + if total > 100 { + total = 100 + } + return total +} + +func classifyScore(score int) RiskLevel { + switch { + case score == 0: + return RiskNone + case score <= 25: + return RiskLow + case score <= 50: + return RiskMedium + case score <= 75: + return RiskHigh + default: + return RiskCritical + } +} + +func generateRecommendations(factors []RiskFactor, hasScript bool) []string { + var recs []string + hasHighRiskBin := false + hasSensitiveEnv := false + hasUnknownDomain := false + + for _, f := range factors { + switch { + case f.Category == "binary" && f.Points >= 15: + hasHighRiskBin = true + case f.Category == "env" && f.Points >= 10: + hasSensitiveEnv = true + case f.Category == "egress" && f.Points >= 10: + hasUnknownDomain = true + } + } + + if hasHighRiskBin { + recs = append(recs, "Review high-risk binary usage; consider restricting to specific commands") + } + if hasSensitiveEnv { + recs = append(recs, "Ensure sensitive credentials are rotated regularly and scoped minimally") + } + if hasUnknownDomain { + recs = append(recs, "Verify unknown egress domains are expected and add to trusted list if appropriate") + } + if hasScript { + recs = append(recs, "Audit executable script content for security issues before deployment") + } + return recs +} diff --git a/forge-skills/analyzer/scoring_test.go b/forge-skills/analyzer/scoring_test.go new file mode 100644 index 0000000..787aa9d --- /dev/null +++ b/forge-skills/analyzer/scoring_test.go @@ -0,0 +1,184 @@ +package analyzer + +import ( + "testing" + + "github.com/initializ/forge/forge-skills/contract" +) + +func TestAnalyzeSkillDescriptor_NoRisk(t *testing.T) { + sd := &contract.SkillDescriptor{Name: "simple"} + a := AnalyzeSkillDescriptor(sd, false) + + if a.Score.Value != 0 { + t.Fatalf("expected score 0, got %d", a.Score.Value) + } + if a.Score.Level != RiskNone { + t.Fatalf("expected level none, got %s", a.Score.Level) + } +} + +func TestAnalyzeSkillDescriptor_TrustedDomain(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "github", + EgressDomains: []string{"api.github.com"}, + } + a := AnalyzeSkillDescriptor(sd, false) + + if a.Score.Value != 2 { + t.Fatalf("expected score 2, got %d", a.Score.Value) + } +} + +func TestAnalyzeSkillDescriptor_UnknownDomain(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "custom", + EgressDomains: []string{"evil.example.com"}, + } + a := AnalyzeSkillDescriptor(sd, false) + + if a.Score.Value != 10 { + t.Fatalf("expected score 10, got %d", a.Score.Value) + } +} + +func TestAnalyzeSkillDescriptor_HighRiskBinary(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "shell-tool", + RequiredBins: []string{"bash"}, + } + a := AnalyzeSkillDescriptor(sd, false) + + if a.Score.Value != 15 { + t.Fatalf("expected score 15, got %d", a.Score.Value) + } +} + +func TestAnalyzeSkillDescriptor_StandardBinary(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "api-tool", + RequiredBins: []string{"curl"}, + } + a := AnalyzeSkillDescriptor(sd, false) + + if a.Score.Value != 3 { + t.Fatalf("expected score 3, got %d", a.Score.Value) + } +} + +func TestAnalyzeSkillDescriptor_SensitiveEnv(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "secret-tool", + RequiredEnv: []string{"AWS_SECRET_ACCESS_KEY"}, + } + a := AnalyzeSkillDescriptor(sd, false) + + if a.Score.Value != 10 { + t.Fatalf("expected score 10, got %d", a.Score.Value) + } +} + +func TestAnalyzeSkillDescriptor_StandardAPIKey(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "api-tool", + RequiredEnv: []string{"GH_TOKEN"}, + } + a := AnalyzeSkillDescriptor(sd, false) + + if a.Score.Value != 5 { + t.Fatalf("expected score 5, got %d", a.Score.Value) + } +} + +func TestAnalyzeSkillDescriptor_WithScript(t *testing.T) { + sd := &contract.SkillDescriptor{Name: "scripted"} + a := AnalyzeSkillDescriptor(sd, true) + + if a.Score.Value != 20 { + t.Fatalf("expected score 20, got %d", a.Score.Value) + } +} + +func TestAnalyzeSkillDescriptor_Combined(t *testing.T) { + sd := &contract.SkillDescriptor{ + Name: "github", + EgressDomains: []string{"api.github.com", "github.com"}, + RequiredBins: []string{"gh"}, + RequiredEnv: []string{"GH_TOKEN"}, + } + a := AnalyzeSkillDescriptor(sd, false) + + // 2 + 2 + 3 + 5 = 12 + expected := 12 + if a.Score.Value != expected { + t.Fatalf("expected score %d, got %d", expected, a.Score.Value) + } + if a.Score.Level != RiskLow { + t.Fatalf("expected level low, got %s", a.Score.Level) + } +} + +func TestAnalyzeSkillDescriptor_CappedAt100(t *testing.T) { + // Create a skill with many high-risk factors + sd := &contract.SkillDescriptor{ + Name: "mega-risk", + EgressDomains: []string{"a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}, + RequiredBins: []string{"bash", "python", "ssh", "nc"}, + RequiredEnv: []string{"AWS_SECRET_ACCESS_KEY", "DB_PASSWORD"}, + } + a := AnalyzeSkillDescriptor(sd, true) + + if a.Score.Value > 100 { + t.Fatalf("score should be capped at 100, got %d", a.Score.Value) + } +} + +func TestAnalyzeSkillDescriptor_ManyDomainBonus(t *testing.T) { + domains := []string{"a.com", "b.com", "c.com", "d.com", "e.com", "f.com"} + sd := &contract.SkillDescriptor{ + Name: "many-domains", + EgressDomains: domains, + } + a := AnalyzeSkillDescriptor(sd, false) + + // 6 unknown domains * 10 = 60, plus bonus 15 = 75 + if a.Score.Value != 75 { + t.Fatalf("expected score 75, got %d", a.Score.Value) + } +} + +func TestClassifyScore(t *testing.T) { + tests := []struct { + score int + level RiskLevel + }{ + {0, RiskNone}, + {1, RiskLow}, + {25, RiskLow}, + {26, RiskMedium}, + {50, RiskMedium}, + {51, RiskHigh}, + {75, RiskHigh}, + {76, RiskCritical}, + {100, RiskCritical}, + } + for _, tt := range tests { + got := classifyScore(tt.score) + if got != tt.level { + t.Errorf("classifyScore(%d) = %s, want %s", tt.score, got, tt.level) + } + } +} + +func TestGenerateRecommendations(t *testing.T) { + factors := []RiskFactor{ + {Category: "binary", Points: 15}, + {Category: "env", Points: 10}, + {Category: "egress", Points: 10}, + } + recs := generateRecommendations(factors, true) + + if len(recs) < 3 { + t.Fatalf("expected at least 3 recommendations, got %d", len(recs)) + } +} diff --git a/forge-skills/analyzer/types.go b/forge-skills/analyzer/types.go new file mode 100644 index 0000000..941d2e4 --- /dev/null +++ b/forge-skills/analyzer/types.go @@ -0,0 +1,70 @@ +// Package analyzer provides security risk scoring, policy enforcement, and +// audit reporting for forge skills. +package analyzer + +// RiskLevel classifies the severity of a risk assessment. +type RiskLevel string + +const ( + RiskNone RiskLevel = "none" + RiskLow RiskLevel = "low" + RiskMedium RiskLevel = "medium" + RiskHigh RiskLevel = "high" + RiskCritical RiskLevel = "critical" +) + +// RiskScore is a numeric score with a classified risk level. +type RiskScore struct { + Value int `json:"value"` // 0-100 + Level RiskLevel `json:"level"` +} + +// RiskFactor is a single contributing factor to a risk score. +type RiskFactor struct { + Category string `json:"category"` // "egress", "binary", "env", "script" + Description string `json:"description"` + Points int `json:"points"` +} + +// SkillRiskAssessment is the security assessment for a single skill. +type SkillRiskAssessment struct { + SkillName string `json:"skill_name"` + Score RiskScore `json:"score"` + Factors []RiskFactor `json:"factors"` + Violations []PolicyViolation `json:"violations,omitempty"` + Recommendations []string `json:"recommendations,omitempty"` +} + +// PolicyViolation describes a security policy breach. +type PolicyViolation struct { + Rule string `json:"rule"` + Severity string `json:"severity"` // "error", "warning" + Message string `json:"message"` +} + +// AuditReport is the complete security audit output. +type AuditReport struct { + Timestamp string `json:"timestamp"` + SkillCount int `json:"skill_count"` + AggregateScore RiskScore `json:"aggregate_score"` + Assessments []SkillRiskAssessment `json:"assessments"` + PolicySummary PolicySummary `json:"policy_summary"` +} + +// PolicySummary aggregates policy violation counts. +type PolicySummary struct { + TotalViolations int `json:"total_violations"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Passed bool `json:"passed"` +} + +// SecurityPolicy defines configurable security rules. +type SecurityPolicy struct { + MaxEgressDomains int `yaml:"max_egress_domains" json:"max_egress_domains"` + BinaryDenylist []string `yaml:"binary_denylist" json:"binary_denylist,omitempty"` + DeniedEnvPatterns []string `yaml:"denied_env_patterns" json:"denied_env_patterns,omitempty"` + ScriptPolicy string `yaml:"script_policy" json:"script_policy"` // "allow"|"warn"|"deny" + MaxRiskScore int `yaml:"max_risk_score" json:"max_risk_score"` + TrustedDomains []string `yaml:"trusted_domains" json:"trusted_domains,omitempty"` +} diff --git a/forge-core/skills/compiler.go b/forge-skills/compiler/compiler.go similarity index 54% rename from forge-core/skills/compiler.go rename to forge-skills/compiler/compiler.go index 4ab4541..b2984ae 100644 --- a/forge-core/skills/compiler.go +++ b/forge-skills/compiler/compiler.go @@ -1,30 +1,17 @@ -package skills +// Package compiler converts parsed SkillEntry values into CompiledSkills. +package compiler import ( "fmt" "strings" -) - -// CompiledSkills holds the result of compiling skill entries. -type CompiledSkills struct { - Skills []CompiledSkill `json:"skills"` - Count int `json:"count"` - Version string `json:"version"` - Prompt string `json:"-"` // written separately as prompt.txt -} -// CompiledSkill represents a single compiled skill. -type CompiledSkill struct { - Name string `json:"name"` - Description string `json:"description"` - InputSpec string `json:"input_spec,omitempty"` - OutputSpec string `json:"output_spec,omitempty"` -} + "github.com/initializ/forge/forge-skills/contract" +) // Compile converts parsed SkillEntry values into CompiledSkills. -func Compile(entries []SkillEntry) (*CompiledSkills, error) { - cs := &CompiledSkills{ - Skills: make([]CompiledSkill, 0, len(entries)), +func Compile(entries []contract.SkillEntry) (*contract.CompiledSkills, error) { + cs := &contract.CompiledSkills{ + Skills: make([]contract.CompiledSkill, 0, len(entries)), Version: "agentskills-v1", } @@ -32,7 +19,7 @@ func Compile(entries []SkillEntry) (*CompiledSkills, error) { promptBuilder.WriteString("# Available Skills\n\n") for _, e := range entries { - skill := CompiledSkill{ + skill := contract.CompiledSkill{ Name: e.Name, Description: e.Description, InputSpec: e.InputSpec, diff --git a/forge-core/skills/compiler_test.go b/forge-skills/compiler/compiler_test.go similarity index 87% rename from forge-core/skills/compiler_test.go rename to forge-skills/compiler/compiler_test.go index 6509ffd..ab643bc 100644 --- a/forge-core/skills/compiler_test.go +++ b/forge-skills/compiler/compiler_test.go @@ -1,9 +1,11 @@ -package skills +package compiler import ( "encoding/json" "strings" "testing" + + "github.com/initializ/forge/forge-skills/contract" ) func TestCompile_Empty(t *testing.T) { @@ -23,9 +25,9 @@ func TestCompile_Empty(t *testing.T) { } func TestCompile_MultipleSkills(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ {Name: "web_search", Description: "Search the web", InputSpec: "query: string", OutputSpec: "results: []string"}, - {Name: "summarize", Description: "Summarize text", InputSpec: "text: string"}, + {Name: "translate", Description: "Translate text", InputSpec: "text: string"}, } cs, err := Compile(entries) @@ -62,7 +64,7 @@ func TestCompile_MultipleSkills(t *testing.T) { } func TestCompile_SingleSkill(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ {Name: "translate", Description: "Translate text between languages", InputSpec: "text: string, target_lang: string", OutputSpec: "translated: string"}, } @@ -88,9 +90,9 @@ func TestCompile_SingleSkill(t *testing.T) { } func TestCompile_PromptContainsNames(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ {Name: "web_search", Description: "Search the internet"}, - {Name: "summarize", Description: "Summarize long text"}, + {Name: "translate", Description: "Translate long text"}, } cs, err := Compile(entries) @@ -101,8 +103,8 @@ func TestCompile_PromptContainsNames(t *testing.T) { if !strings.Contains(cs.Prompt, "web_search") { t.Error("Prompt should contain skill name 'web_search'") } - if !strings.Contains(cs.Prompt, "summarize") { - t.Error("Prompt should contain skill name 'summarize'") + if !strings.Contains(cs.Prompt, "translate") { + t.Error("Prompt should contain skill name 'translate'") } if !strings.Contains(cs.Prompt, "Search the internet") { t.Error("Prompt should contain skill description") @@ -110,7 +112,7 @@ func TestCompile_PromptContainsNames(t *testing.T) { } func TestCompile_EmptyDescription(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ {Name: "no_desc_skill", Description: ""}, } diff --git a/forge-skills/contract/registry.go b/forge-skills/contract/registry.go new file mode 100644 index 0000000..fd230a5 --- /dev/null +++ b/forge-skills/contract/registry.go @@ -0,0 +1,20 @@ +// Package contract defines the interfaces and types shared across forge-skills subpackages. +package contract + +// SkillRegistry provides read access to a collection of skill descriptors. +type SkillRegistry interface { + // List returns all available skill descriptors. + List() ([]SkillDescriptor, error) + + // Get returns the descriptor for the named skill, or nil if not found. + Get(name string) *SkillDescriptor + + // LoadContent reads the full SKILL.md content for the named skill. + LoadContent(name string) ([]byte, error) + + // HasScript reports whether the named skill has an associated script. + HasScript(name string) bool + + // LoadScript reads the script content for the named skill. + LoadScript(name string) ([]byte, error) +} diff --git a/forge-skills/contract/types.go b/forge-skills/contract/types.go new file mode 100644 index 0000000..7c01e7d --- /dev/null +++ b/forge-skills/contract/types.go @@ -0,0 +1,118 @@ +package contract + +// SkillDescriptor describes a skill available in a registry. +type SkillDescriptor struct { + Name string + DisplayName string + Description string + RequiredEnv []string + OneOfEnv []string + OptionalEnv []string + RequiredBins []string + EgressDomains []string + Provenance *Provenance `json:"provenance,omitempty"` +} + +// SkillEntry represents a single tool/skill parsed from a SKILL.md file. +type SkillEntry struct { + Name string + Description string + InputSpec string + OutputSpec string + Metadata *SkillMetadata // nil if no frontmatter + ForgeReqs *SkillRequirements // convenience: extracted from metadata.forge.requires +} + +// SkillMetadata holds the full frontmatter parsed from YAML between --- delimiters. +// Uses map to tolerate unknown namespaces (e.g. clawdbot:). +type SkillMetadata struct { + Name string `yaml:"name,omitempty"` + Description string `yaml:"description,omitempty"` + Metadata map[string]map[string]any `yaml:"metadata,omitempty"` +} + +// ForgeSkillMeta holds Forge-specific metadata from the "forge" namespace. +type ForgeSkillMeta struct { + Requires *SkillRequirements `yaml:"requires,omitempty" json:"requires,omitempty"` + EgressDomains []string `yaml:"egress_domains,omitempty" json:"egress_domains,omitempty"` +} + +// SkillRequirements declares CLI binaries and environment variables a skill needs. +type SkillRequirements struct { + Bins []string `yaml:"bins,omitempty" json:"bins,omitempty"` + Env *EnvRequirements `yaml:"env,omitempty" json:"env,omitempty"` +} + +// EnvRequirements declares environment variable requirements at different levels. +type EnvRequirements struct { + Required []string `yaml:"required,omitempty" json:"required,omitempty"` + OneOf []string `yaml:"one_of,omitempty" json:"one_of,omitempty"` + Optional []string `yaml:"optional,omitempty" json:"optional,omitempty"` +} + +// CompiledSkills holds the result of compiling skill entries. +type CompiledSkills struct { + Skills []CompiledSkill `json:"skills"` + Count int `json:"count"` + Version string `json:"version"` + Prompt string `json:"-"` // written separately as prompt.txt +} + +// CompiledSkill represents a single compiled skill. +type CompiledSkill struct { + Name string `json:"name"` + Description string `json:"description"` + InputSpec string `json:"input_spec,omitempty"` + OutputSpec string `json:"output_spec,omitempty"` +} + +// AggregatedRequirements is the union of all skill requirements. +type AggregatedRequirements struct { + Bins []string // union of all bins, deduplicated, sorted + EnvRequired []string // union of required vars (promoted from optional if needed) + EnvOneOf [][]string // separate groups per skill (not merged across skills) + EnvOptional []string // union of optional vars minus those promoted to required +} + +// DerivedCLIConfig holds auto-derived cli_execute configuration from skill requirements. +type DerivedCLIConfig struct { + AllowedBinaries []string + EnvPassthrough []string +} + +// TrustLevel indicates the trust classification of a skill. +type TrustLevel string + +const ( + TrustBuiltin TrustLevel = "builtin" // embedded in binary + TrustVerified TrustLevel = "verified" // signature checked + TrustLocal TrustLevel = "local" // user's project, no signature + TrustUntrusted TrustLevel = "untrusted" // unknown origin +) + +// Provenance records the origin and integrity metadata for a skill. +type Provenance struct { + Source string `json:"source"` // "embedded", "local", "remote" + Author string `json:"author,omitempty"` // signer identity + Version string `json:"version,omitempty"` // skill version from frontmatter + Trust TrustLevel `json:"trust"` // trust classification + Checksum string `json:"checksum"` // "sha256:" of SKILL.md content + SignedBy string `json:"signed_by,omitempty"` // key ID if signed, empty if not +} + +// EnvSource describes where an environment variable was found. +type EnvSource string + +const ( + EnvSourceOS EnvSource = "environment" + EnvSourceDotEnv EnvSource = "dotenv" + EnvSourceConfig EnvSource = "config" + EnvSourceMissing EnvSource = "missing" +) + +// ValidationDiagnostic represents a single validation finding. +type ValidationDiagnostic struct { + Level string // "error", "warning", "info" + Message string + Var string +} diff --git a/forge-skills/contract/types_test.go b/forge-skills/contract/types_test.go new file mode 100644 index 0000000..8904859 --- /dev/null +++ b/forge-skills/contract/types_test.go @@ -0,0 +1,45 @@ +package contract + +import "testing" + +// Compile-time interface check: verify SkillRegistry is implementable. +var _ SkillRegistry = (*registryStub)(nil) + +type registryStub struct{} + +func (registryStub) List() ([]SkillDescriptor, error) { return nil, nil } +func (registryStub) Get(string) *SkillDescriptor { return nil } +func (registryStub) LoadContent(string) ([]byte, error) { + return nil, nil +} +func (registryStub) HasScript(string) bool { return false } +func (registryStub) LoadScript(string) ([]byte, error) { return nil, nil } + +func TestSkillDescriptorFields(t *testing.T) { + sd := SkillDescriptor{ + Name: "github", + DisplayName: "GitHub", + Description: "Create issues, PRs, and query repositories", + RequiredEnv: []string{"GH_TOKEN"}, + RequiredBins: []string{"gh"}, + EgressDomains: []string{"api.github.com", "github.com"}, + } + if sd.Name != "github" { + t.Errorf("Name = %q, want github", sd.Name) + } + if len(sd.EgressDomains) != 2 { + t.Errorf("EgressDomains = %v, want 2 items", sd.EgressDomains) + } +} + +func TestSkillEntryFields(t *testing.T) { + se := SkillEntry{ + Name: "web_search", + Description: "Search the web", + InputSpec: "query: string", + OutputSpec: "results: []string", + } + if se.Name != "web_search" { + t.Errorf("Name = %q, want web_search", se.Name) + } +} diff --git a/forge-skills/go.mod b/forge-skills/go.mod new file mode 100644 index 0000000..1d4d919 --- /dev/null +++ b/forge-skills/go.mod @@ -0,0 +1,5 @@ +module github.com/initializ/forge/forge-skills + +go 1.25.0 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/forge-skills/go.sum b/forge-skills/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/forge-skills/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/forge-skills/local/embed.go b/forge-skills/local/embed.go new file mode 100644 index 0000000..45fa4a1 --- /dev/null +++ b/forge-skills/local/embed.go @@ -0,0 +1,6 @@ +package local + +import "embed" + +//go:embed embedded +var embeddedSkillsFS embed.FS diff --git a/forge-core/registry/skills/github.md b/forge-skills/local/embedded/github/SKILL.md similarity index 84% rename from forge-core/registry/skills/github.md rename to forge-skills/local/embedded/github/SKILL.md index 590e64b..2e2f8f6 100644 --- a/forge-core/registry/skills/github.md +++ b/forge-skills/local/embedded/github/SKILL.md @@ -1,6 +1,6 @@ --- name: github -description: GitHub integration skill +description: Create issues, PRs, and query repositories metadata: forge: requires: @@ -11,6 +11,9 @@ metadata: - GH_TOKEN one_of: [] optional: [] + egress_domains: + - api.github.com + - github.com --- ## Tool: github_create_issue diff --git a/forge-core/registry/skills/tavily-search.md b/forge-skills/local/embedded/tavily-search/SKILL.md similarity index 98% rename from forge-core/registry/skills/tavily-search.md rename to forge-skills/local/embedded/tavily-search/SKILL.md index 08a08c5..9ca9813 100644 --- a/forge-core/registry/skills/tavily-search.md +++ b/forge-skills/local/embedded/tavily-search/SKILL.md @@ -12,6 +12,8 @@ metadata: - TAVILY_API_KEY one_of: [] optional: [] + egress_domains: + - api.tavily.com --- # Tavily Web Search Skill diff --git a/forge-core/registry/scripts/tavily-search.sh b/forge-skills/local/embedded/tavily-search/scripts/tavily-search.sh similarity index 100% rename from forge-core/registry/scripts/tavily-search.sh rename to forge-skills/local/embedded/tavily-search/scripts/tavily-search.sh diff --git a/forge-core/registry/skills/weather.md b/forge-skills/local/embedded/weather/SKILL.md similarity index 80% rename from forge-core/registry/skills/weather.md rename to forge-skills/local/embedded/weather/SKILL.md index 32a90fa..786ac21 100644 --- a/forge-core/registry/skills/weather.md +++ b/forge-skills/local/embedded/weather/SKILL.md @@ -1,6 +1,6 @@ --- name: weather -description: Weather data skill +description: Get current weather and forecasts metadata: forge: requires: @@ -10,6 +10,9 @@ metadata: required: [] one_of: [] optional: [] + egress_domains: + - api.openweathermap.org + - api.weatherapi.com --- ## Tool: weather_current diff --git a/forge-skills/local/registry.go b/forge-skills/local/registry.go new file mode 100644 index 0000000..6307812 --- /dev/null +++ b/forge-skills/local/registry.go @@ -0,0 +1,91 @@ +package local + +import ( + "fmt" + "io/fs" + + "github.com/initializ/forge/forge-skills/contract" +) + +// LocalRegistry implements contract.SkillRegistry backed by an fs.FS. +type LocalRegistry struct { + fsys fs.FS + skills []contract.SkillDescriptor + byName map[string]*contract.SkillDescriptor +} + +// NewLocalRegistry creates a LocalRegistry by scanning the given filesystem. +func NewLocalRegistry(fsys fs.FS) (*LocalRegistry, error) { + skills, err := Scan(fsys) + if err != nil { + return nil, fmt.Errorf("scanning skills: %w", err) + } + + byName := make(map[string]*contract.SkillDescriptor, len(skills)) + for i := range skills { + byName[skills[i].Name] = &skills[i] + } + + return &LocalRegistry{ + fsys: fsys, + skills: skills, + byName: byName, + }, nil +} + +// NewEmbeddedRegistry creates a LocalRegistry backed by the compile-time embedded skills. +// Embedded skills are assigned TrustBuiltin provenance. +func NewEmbeddedRegistry() (*LocalRegistry, error) { + sub, err := fs.Sub(embeddedSkillsFS, "embedded") + if err != nil { + return nil, fmt.Errorf("accessing embedded skills: %w", err) + } + reg, err := NewLocalRegistry(sub) + if err != nil { + return nil, err + } + // Upgrade trust level for embedded skills + for i := range reg.skills { + if reg.skills[i].Provenance != nil { + reg.skills[i].Provenance.Source = "embedded" + reg.skills[i].Provenance.Trust = contract.TrustBuiltin + } + } + return reg, nil +} + +// List returns all available skill descriptors. +func (r *LocalRegistry) List() ([]contract.SkillDescriptor, error) { + return r.skills, nil +} + +// Get returns the descriptor for the named skill, or nil if not found. +func (r *LocalRegistry) Get(name string) *contract.SkillDescriptor { + return r.byName[name] +} + +// LoadContent reads the full SKILL.md content for the named skill. +func (r *LocalRegistry) LoadContent(name string) ([]byte, error) { + dirName := r.dirName(name) + return fs.ReadFile(r.fsys, dirName+"/SKILL.md") +} + +// HasScript reports whether the named skill has an associated script. +func (r *LocalRegistry) HasScript(name string) bool { + dirName := r.dirName(name) + _, err := fs.ReadFile(r.fsys, dirName+"/scripts/"+name+".sh") + return err == nil +} + +// LoadScript reads the script content for the named skill. +func (r *LocalRegistry) LoadScript(name string) ([]byte, error) { + dirName := r.dirName(name) + return fs.ReadFile(r.fsys, dirName+"/scripts/"+name+".sh") +} + +// dirName returns the directory name for a skill. The directory name matches +// the skill name (which is the directory name used in the embedded FS). +func (r *LocalRegistry) dirName(name string) string { + // The directory name is the same as the skill name + return name +} diff --git a/forge-skills/local/registry_embedded_test.go b/forge-skills/local/registry_embedded_test.go new file mode 100644 index 0000000..c176a82 --- /dev/null +++ b/forge-skills/local/registry_embedded_test.go @@ -0,0 +1,168 @@ +package local + +import ( + "strings" + "testing" +) + +func TestEmbeddedRegistry_DiscoverAll(t *testing.T) { + reg, err := NewEmbeddedRegistry() + if err != nil { + t.Fatalf("NewEmbeddedRegistry error: %v", err) + } + + skills, err := reg.List() + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(skills) != 3 { + names := make([]string, len(skills)) + for i, s := range skills { + names[i] = s.Name + } + t.Fatalf("expected 3 skills, got %d: %v", len(skills), names) + } + + // Verify all expected skills are present + expectedSkills := map[string]struct { + displayName string + hasEnv bool + hasBins bool + hasEgress bool + }{ + "github": {displayName: "Github", hasEnv: true, hasBins: true, hasEgress: true}, + "weather": {displayName: "Weather", hasEnv: false, hasBins: true, hasEgress: true}, + "tavily-search": {displayName: "Tavily Search", hasEnv: true, hasBins: true, hasEgress: true}, + } + + for _, s := range skills { + exp, ok := expectedSkills[s.Name] + if !ok { + t.Errorf("unexpected skill %q", s.Name) + continue + } + if s.DisplayName != exp.displayName { + t.Errorf("skill %q: DisplayName = %q, want %q", s.Name, s.DisplayName, exp.displayName) + } + if s.Description == "" { + t.Errorf("skill %q: empty Description", s.Name) + } + if exp.hasEnv && len(s.RequiredEnv) == 0 { + t.Errorf("skill %q: expected RequiredEnv", s.Name) + } + if exp.hasBins && len(s.RequiredBins) == 0 { + t.Errorf("skill %q: expected RequiredBins", s.Name) + } + if exp.hasEgress && len(s.EgressDomains) == 0 { + t.Errorf("skill %q: expected EgressDomains", s.Name) + } + } +} + +func TestEmbeddedRegistry_GitHubDetails(t *testing.T) { + reg, err := NewEmbeddedRegistry() + if err != nil { + t.Fatalf("NewEmbeddedRegistry error: %v", err) + } + + s := reg.Get("github") + if s == nil { + t.Fatal("Get(\"github\") returned nil") + } + if s.Description != "Create issues, PRs, and query repositories" { + t.Errorf("Description = %q", s.Description) + } + if len(s.RequiredEnv) != 1 || s.RequiredEnv[0] != "GH_TOKEN" { + t.Errorf("RequiredEnv = %v", s.RequiredEnv) + } + if len(s.RequiredBins) != 1 || s.RequiredBins[0] != "gh" { + t.Errorf("RequiredBins = %v", s.RequiredBins) + } + + foundDomain := false + for _, d := range s.EgressDomains { + if d == "api.github.com" { + foundDomain = true + } + } + if !foundDomain { + t.Errorf("EgressDomains = %v, want api.github.com", s.EgressDomains) + } +} + +func TestEmbeddedRegistry_TavilySearchDetails(t *testing.T) { + reg, err := NewEmbeddedRegistry() + if err != nil { + t.Fatalf("NewEmbeddedRegistry error: %v", err) + } + + s := reg.Get("tavily-search") + if s == nil { + t.Fatal("Get(\"tavily-search\") returned nil") + } + if len(s.RequiredEnv) != 1 || s.RequiredEnv[0] != "TAVILY_API_KEY" { + t.Errorf("RequiredEnv = %v", s.RequiredEnv) + } + if len(s.RequiredBins) < 2 { + t.Errorf("RequiredBins = %v, want at least [curl, jq]", s.RequiredBins) + } + + foundDomain := false + for _, d := range s.EgressDomains { + if d == "api.tavily.com" { + foundDomain = true + } + } + if !foundDomain { + t.Errorf("EgressDomains = %v, want api.tavily.com", s.EgressDomains) + } + + // Check script + if !reg.HasScript("tavily-search") { + t.Error("tavily-search should have a script") + } + script, err := reg.LoadScript("tavily-search") + if err != nil { + t.Fatalf("LoadScript error: %v", err) + } + if !strings.Contains(string(script), "TAVILY_API_KEY") { + t.Error("script should reference TAVILY_API_KEY") + } +} + +func TestEmbeddedRegistry_LoadContent(t *testing.T) { + reg, err := NewEmbeddedRegistry() + if err != nil { + t.Fatalf("NewEmbeddedRegistry error: %v", err) + } + + skills, _ := reg.List() + for _, s := range skills { + content, err := reg.LoadContent(s.Name) + if err != nil { + t.Errorf("LoadContent(%q) error: %v", s.Name, err) + continue + } + if len(content) == 0 { + t.Errorf("LoadContent(%q) returned empty content", s.Name) + } + if !strings.Contains(string(content), "## Tool:") { + t.Errorf("LoadContent(%q) missing '## Tool:' heading", s.Name) + } + } +} + +func TestEmbeddedRegistry_NonexistentSkill(t *testing.T) { + reg, err := NewEmbeddedRegistry() + if err != nil { + t.Fatalf("NewEmbeddedRegistry error: %v", err) + } + + if reg.Get("nonexistent") != nil { + t.Error("Get(\"nonexistent\") should return nil") + } + if reg.HasScript("nonexistent") { + t.Error("HasScript(\"nonexistent\") should return false") + } +} diff --git a/forge-skills/local/registry_test.go b/forge-skills/local/registry_test.go new file mode 100644 index 0000000..f9bddfc --- /dev/null +++ b/forge-skills/local/registry_test.go @@ -0,0 +1,117 @@ +package local + +import ( + "testing" + "testing/fstest" +) + +func TestLocalRegistry_Basic(t *testing.T) { + fsys := fstest.MapFS{ + "github/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: github +description: GitHub integration +metadata: + forge: + requires: + bins: + - gh + env: + required: + - GH_TOKEN + egress_domains: + - api.github.com +--- +## Tool: github_create_issue +Create issues. +`), + }, + } + + reg, err := NewLocalRegistry(fsys) + if err != nil { + t.Fatalf("NewLocalRegistry error: %v", err) + } + + // List + skills, err := reg.List() + if err != nil { + t.Fatalf("List error: %v", err) + } + if len(skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(skills)) + } + + // Get + sd := reg.Get("github") + if sd == nil { + t.Fatal("Get(\"github\") returned nil") + } + if sd.Description != "GitHub integration" { + t.Errorf("Description = %q", sd.Description) + } + + // Get nonexistent + if reg.Get("nonexistent") != nil { + t.Error("Get(\"nonexistent\") should return nil") + } + + // LoadContent + content, err := reg.LoadContent("github") + if err != nil { + t.Fatalf("LoadContent error: %v", err) + } + if len(content) == 0 { + t.Error("LoadContent returned empty content") + } + + // HasScript / LoadScript + if reg.HasScript("github") { + t.Error("github should not have a script") + } +} + +func TestLocalRegistry_WithScript(t *testing.T) { + fsys := fstest.MapFS{ + "tavily-search/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: tavily-search +description: Web search +metadata: + forge: + requires: + bins: + - curl + - jq + env: + required: + - TAVILY_API_KEY + egress_domains: + - api.tavily.com +--- +## Tool: tavily_search +Search. +`), + }, + "tavily-search/scripts/tavily-search.sh": &fstest.MapFile{ + Data: []byte("#!/bin/bash\necho hello\n"), + }, + } + + reg, err := NewLocalRegistry(fsys) + if err != nil { + t.Fatalf("NewLocalRegistry error: %v", err) + } + + if !reg.HasScript("tavily-search") { + t.Error("tavily-search should have a script") + } + + script, err := reg.LoadScript("tavily-search") + if err != nil { + t.Fatalf("LoadScript error: %v", err) + } + if len(script) == 0 { + t.Error("LoadScript returned empty content") + } +} diff --git a/forge-skills/local/scanner.go b/forge-skills/local/scanner.go new file mode 100644 index 0000000..eeb8232 --- /dev/null +++ b/forge-skills/local/scanner.go @@ -0,0 +1,181 @@ +package local + +import ( + "crypto/sha256" + "fmt" + "io/fs" + "strings" + + "github.com/initializ/forge/forge-skills/contract" + "github.com/initializ/forge/forge-skills/parser" +) + +// Scan walks top-level directories in fsys looking for SKILL.md files. +// It parses frontmatter to extract SkillDescriptor fields. +// Hidden directories (starting with ".") and "_template/" are skipped. +func Scan(fsys fs.FS) ([]contract.SkillDescriptor, error) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, err + } + + var skills []contract.SkillDescriptor + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + continue + } + + // Check for SKILL.md + skillPath := name + "/SKILL.md" + f, err := fsys.Open(skillPath) + if err != nil { + continue // no SKILL.md, skip + } + + // Read raw content for checksum computation + rawContent, readErr := fs.ReadFile(fsys, skillPath) + if readErr != nil { + _ = f.(interface{ Close() error }).Close() + continue + } + + // Parse frontmatter + _, meta, parseErr := parser.ParseWithMetadata(f) + _ = f.(interface{ Close() error }).Close() + if parseErr != nil { + continue + } + + // Compute checksum + h := sha256.Sum256(rawContent) + checksum := fmt.Sprintf("sha256:%x", h) + + sd := contract.SkillDescriptor{ + Name: name, + Provenance: &contract.Provenance{ + Source: "local", + Trust: contract.TrustLocal, + Checksum: checksum, + }, + } + + if meta != nil { + if meta.Name != "" { + sd.Name = meta.Name + } + if meta.Description != "" { + sd.Description = meta.Description + } + + // Extract forge-specific fields + if meta.Metadata != nil { + if forgeMap, ok := meta.Metadata["forge"]; ok { + sd.RequiredBins, sd.RequiredEnv, sd.OneOfEnv, sd.OptionalEnv, sd.EgressDomains = extractFromForgeMap(forgeMap) + } + } + + // Extract version from frontmatter if present + if sd.Provenance != nil && meta.Metadata != nil { + if forgeMap, ok := meta.Metadata["forge"]; ok { + if v, ok := forgeMap["version"]; ok { + if vs, ok := v.(string); ok { + sd.Provenance.Version = vs + } + } + } + } + } + + // Derive display name from skill name if not set + if sd.DisplayName == "" { + sd.DisplayName = deriveDisplayName(sd.Name) + } + + skills = append(skills, sd) + } + + return skills, nil +} + +// extractFromForgeMap extracts typed fields from the forge metadata map. +func extractFromForgeMap(forgeMap map[string]any) (bins, reqEnv, oneOfEnv, optEnv, egress []string) { + // Extract egress_domains + if raw, ok := forgeMap["egress_domains"]; ok { + if arr, ok := raw.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + egress = append(egress, s) + } + } + } + } + + // Extract requires + reqRaw, ok := forgeMap["requires"] + if !ok { + return + } + reqMap, ok := reqRaw.(map[string]any) + if !ok { + return + } + + // bins + if binsRaw, ok := reqMap["bins"]; ok { + if arr, ok := binsRaw.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + bins = append(bins, s) + } + } + } + } + + // env + envRaw, ok := reqMap["env"] + if !ok { + return + } + envMap, ok := envRaw.(map[string]any) + if !ok { + return + } + + reqEnv = extractStringSlice(envMap, "required") + oneOfEnv = extractStringSlice(envMap, "one_of") + optEnv = extractStringSlice(envMap, "optional") + return +} + +func extractStringSlice(m map[string]any, key string) []string { + raw, ok := m[key] + if !ok { + return nil + } + arr, ok := raw.([]any) + if !ok { + return nil + } + var result []string + for _, v := range arr { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + return result +} + +// deriveDisplayName converts a kebab-case skill name to a title-case display name. +func deriveDisplayName(name string) string { + parts := strings.Split(name, "-") + for i, p := range parts { + if len(p) > 0 { + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + } + return strings.Join(parts, " ") +} diff --git a/forge-skills/local/scanner_test.go b/forge-skills/local/scanner_test.go new file mode 100644 index 0000000..336d9a7 --- /dev/null +++ b/forge-skills/local/scanner_test.go @@ -0,0 +1,179 @@ +package local + +import ( + "testing" + "testing/fstest" +) + +func TestScan_ValidSkills(t *testing.T) { + fsys := fstest.MapFS{ + "github/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: github +description: GitHub integration +metadata: + forge: + requires: + bins: + - gh + env: + required: + - GH_TOKEN + egress_domains: + - api.github.com +--- +## Tool: github_create_issue +Create issues. +`), + }, + "weather/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: weather +description: Weather data +metadata: + forge: + requires: + bins: + - curl + egress_domains: + - api.openweathermap.org +--- +## Tool: weather_current +Get current weather. +`), + }, + } + + skills, err := Scan(fsys) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(skills) != 2 { + t.Fatalf("expected 2 skills, got %d", len(skills)) + } + + // Find github + var github *struct { + name, desc string + bins, env []string + egress []string + } + for _, s := range skills { + if s.Name == "github" { + github = &struct { + name, desc string + bins, env []string + egress []string + }{s.Name, s.Description, s.RequiredBins, s.RequiredEnv, s.EgressDomains} + } + } + if github == nil { + t.Fatal("github skill not found") + } + if github.desc != "GitHub integration" { + t.Errorf("github description = %q", github.desc) + } + if len(github.bins) != 1 || github.bins[0] != "gh" { + t.Errorf("github bins = %v", github.bins) + } + if len(github.env) != 1 || github.env[0] != "GH_TOKEN" { + t.Errorf("github env = %v", github.env) + } + if len(github.egress) != 1 || github.egress[0] != "api.github.com" { + t.Errorf("github egress = %v", github.egress) + } +} + +func TestScan_SkipsHiddenAndTemplate(t *testing.T) { + fsys := fstest.MapFS{ + ".hidden/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: hidden +--- +## Tool: hidden_tool +Hidden. +`), + }, + "_template/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: template +--- +## Tool: template_tool +Template. +`), + }, + "real/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: real +description: A real skill +--- +## Tool: real_tool +Real. +`), + }, + } + + skills, err := Scan(fsys) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(skills)) + } + if skills[0].Name != "real" { + t.Errorf("expected 'real', got %q", skills[0].Name) + } +} + +func TestScan_SkipsDirsWithoutSkillMD(t *testing.T) { + fsys := fstest.MapFS{ + "noskill/README.md": &fstest.MapFile{ + Data: []byte("no skill here"), + }, + "valid/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: valid +description: Valid skill +--- +## Tool: valid_tool +Valid. +`), + }, + } + + skills, err := Scan(fsys) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(skills)) + } + if skills[0].Name != "valid" { + t.Errorf("expected 'valid', got %q", skills[0].Name) + } +} + +func TestScan_DisplayNameDerived(t *testing.T) { + fsys := fstest.MapFS{ + "tavily-search/SKILL.md": &fstest.MapFile{ + Data: []byte(`--- +name: tavily-search +description: Web search +--- +## Tool: tavily_search +Search. +`), + }, + } + + skills, err := Scan(fsys) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(skills)) + } + if skills[0].DisplayName != "Tavily Search" { + t.Errorf("DisplayName = %q, want 'Tavily Search'", skills[0].DisplayName) + } +} diff --git a/forge-core/skills/parser.go b/forge-skills/parser/parser.go similarity index 78% rename from forge-core/skills/parser.go rename to forge-skills/parser/parser.go index c451e98..de71488 100644 --- a/forge-core/skills/parser.go +++ b/forge-skills/parser/parser.go @@ -1,5 +1,5 @@ -// Package skills provides a reusable parser for skills.md files. -package skills +// Package parser provides a reusable parser for SKILL.md files. +package parser import ( "bufio" @@ -7,6 +7,7 @@ import ( "io" "strings" + "github.com/initializ/forge/forge-skills/contract" "gopkg.in/yaml.v3" ) @@ -17,9 +18,9 @@ import ( // - "**Input:** " sets InputSpec on the current entry // - "**Output:** " sets OutputSpec on the current entry // - "- " (single-word/hyphenated list item) creates an entry with Name only (legacy) -func Parse(r io.Reader) ([]SkillEntry, error) { - var entries []SkillEntry - var current *SkillEntry +func Parse(r io.Reader) ([]contract.SkillEntry, error) { + var entries []contract.SkillEntry + var current *contract.SkillEntry finalize := func() { if current != nil { @@ -39,7 +40,7 @@ func Parse(r io.Reader) ([]SkillEntry, error) { finalize() name := strings.TrimSpace(strings.TrimPrefix(trimmed, "## Tool:")) if name != "" { - current = &SkillEntry{Name: name} + current = &contract.SkillEntry{Name: name} } continue } @@ -74,7 +75,7 @@ func Parse(r io.Reader) ([]SkillEntry, error) { if strings.HasPrefix(trimmed, "- ") { name := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) if name != "" && !strings.Contains(name, " ") && len(name) <= 64 { - entries = append(entries, SkillEntry{Name: name}) + entries = append(entries, contract.SkillEntry{Name: name}) } } } @@ -86,7 +87,7 @@ func Parse(r io.Reader) ([]SkillEntry, error) { // ParseWithMetadata extracts optional YAML frontmatter (between --- delimiters) // then passes the markdown body through existing Parse(). Returns entries with // metadata attached, plus the top-level SkillMetadata. -func ParseWithMetadata(r io.Reader) ([]SkillEntry, *SkillMetadata, error) { +func ParseWithMetadata(r io.Reader) ([]contract.SkillEntry, *contract.SkillMetadata, error) { content, err := io.ReadAll(r) if err != nil { return nil, nil, err @@ -94,17 +95,18 @@ func ParseWithMetadata(r io.Reader) ([]SkillEntry, *SkillMetadata, error) { fm, body, hasFM := extractFrontmatter(content) - var meta *SkillMetadata + var meta *contract.SkillMetadata if hasFM { - meta = &SkillMetadata{} + meta = &contract.SkillMetadata{} if err := yaml.Unmarshal(fm, meta); err != nil { return nil, nil, err } } - var forgeReqs *SkillRequirements + var forgeReqs *contract.SkillRequirements + var egressDomains []string if meta != nil { - forgeReqs = extractForgeReqs(meta) + forgeReqs, egressDomains = extractForgeReqs(meta) } entries, err := Parse(bytes.NewReader(body)) @@ -116,6 +118,7 @@ func ParseWithMetadata(r io.Reader) ([]SkillEntry, *SkillMetadata, error) { for i := range entries { entries[i].Metadata = meta entries[i].ForgeReqs = forgeReqs + _ = egressDomains // egress_domains are available via ForgeSkillMeta but not stored on SkillEntry } return entries, meta, nil @@ -168,27 +171,27 @@ func extractFrontmatter(content []byte) ([]byte, []byte, bool) { return fm, body, true } -// extractForgeReqs extracts SkillRequirements from the generic metadata map +// extractForgeReqs extracts SkillRequirements and egress_domains from the generic metadata map // by re-marshaling metadata["forge"] through yaml round-trip into ForgeSkillMeta. -func extractForgeReqs(meta *SkillMetadata) *SkillRequirements { +func extractForgeReqs(meta *contract.SkillMetadata) (*contract.SkillRequirements, []string) { if meta == nil || meta.Metadata == nil { - return nil + return nil, nil } forgeMap, ok := meta.Metadata["forge"] if !ok || forgeMap == nil { - return nil + return nil, nil } // Re-marshal the forge map to YAML, then unmarshal into ForgeSkillMeta data, err := yaml.Marshal(forgeMap) if err != nil { - return nil + return nil, nil } - var forgeMeta ForgeSkillMeta + var forgeMeta contract.ForgeSkillMeta if err := yaml.Unmarshal(data, &forgeMeta); err != nil { - return nil + return nil, nil } - return forgeMeta.Requires + return forgeMeta.Requires, forgeMeta.EgressDomains } diff --git a/forge-core/skills/parser_test.go b/forge-skills/parser/parser_test.go similarity index 88% rename from forge-core/skills/parser_test.go rename to forge-skills/parser/parser_test.go index b3506fc..6778b2e 100644 --- a/forge-core/skills/parser_test.go +++ b/forge-skills/parser/parser_test.go @@ -1,4 +1,4 @@ -package skills +package parser import ( "reflect" @@ -84,9 +84,6 @@ Calls external APIs. if err != nil { t.Fatalf("Parse error: %v", err) } - // api_client from heading, helper_util should NOT be captured because we're inside a tool entry - // Actually, "- helper_util" is inside current entry so it's treated as description text - // That's fine, the legacy list items only work outside of a tool entry if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d: %+v", len(entries), entries) } @@ -184,8 +181,8 @@ A tool for searching the web. func TestParseWithMetadata_WithForgeRequires(t *testing.T) { input := `--- -name: summarize -description: Summarize URLs or files +name: github +description: GitHub integration metadata: forge: requires: @@ -201,11 +198,11 @@ metadata: optional: - FIRECRAWL_API_KEY --- -## Tool: summarize -Summarize URLs or files into concise text. +## Tool: github_create_issue +Create a GitHub issue. **Input:** url: string -**Output:** summary: string +**Output:** issue_url: string ` entries, meta, err := ParseWithMetadata(strings.NewReader(input)) if err != nil { @@ -214,10 +211,10 @@ Summarize URLs or files into concise text. if meta == nil { t.Fatal("expected non-nil metadata") } - if meta.Name != "summarize" { - t.Errorf("meta.Name = %q, want summarize", meta.Name) + if meta.Name != "github" { + t.Errorf("meta.Name = %q, want github", meta.Name) } - if meta.Description != "Summarize URLs or files" { + if meta.Description != "GitHub integration" { t.Errorf("meta.Description = %q", meta.Description) } if len(entries) != 1 { @@ -318,7 +315,6 @@ Tool description. if meta.Description != "From frontmatter" { t.Errorf("meta.Description = %q, want 'From frontmatter'", meta.Description) } - // Entry name comes from ## Tool: heading, metadata is attached if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } @@ -329,3 +325,37 @@ Tool description. t.Error("expected entry metadata to point to same SkillMetadata") } } + +func TestParseWithMetadata_EgressDomains(t *testing.T) { + input := `--- +name: github +description: GitHub integration +metadata: + forge: + requires: + bins: + - gh + env: + required: + - GH_TOKEN + egress_domains: + - api.github.com + - github.com +--- +## Tool: github_create_issue +Create a GitHub issue. +` + entries, _, err := ParseWithMetadata(strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseWithMetadata error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].ForgeReqs == nil { + t.Fatal("expected non-nil ForgeReqs") + } + if !reflect.DeepEqual(entries[0].ForgeReqs.Bins, []string{"gh"}) { + t.Errorf("Bins = %v, want [gh]", entries[0].ForgeReqs.Bins) + } +} diff --git a/forge-core/skills/derive.go b/forge-skills/requirements/derive.go similarity index 75% rename from forge-core/skills/derive.go rename to forge-skills/requirements/derive.go index fd97143..f5ac8c3 100644 --- a/forge-core/skills/derive.go +++ b/forge-skills/requirements/derive.go @@ -1,18 +1,16 @@ -package skills +package requirements -import "sort" +import ( + "sort" -// DerivedCLIConfig holds auto-derived cli_execute configuration from skill requirements. -type DerivedCLIConfig struct { - AllowedBinaries []string - EnvPassthrough []string -} + "github.com/initializ/forge/forge-skills/contract" +) // DeriveCLIConfig produces cli_execute configuration from aggregated requirements. // AllowedBinaries = reqs.Bins, EnvPassthrough = union of all env vars. -func DeriveCLIConfig(reqs *AggregatedRequirements) *DerivedCLIConfig { +func DeriveCLIConfig(reqs *contract.AggregatedRequirements) *contract.DerivedCLIConfig { if reqs == nil { - return &DerivedCLIConfig{} + return &contract.DerivedCLIConfig{} } envSet := make(map[string]bool) @@ -37,7 +35,7 @@ func DeriveCLIConfig(reqs *AggregatedRequirements) *DerivedCLIConfig { sort.Strings(envPass) } - return &DerivedCLIConfig{ + return &contract.DerivedCLIConfig{ AllowedBinaries: reqs.Bins, // already sorted from AggregateRequirements EnvPassthrough: envPass, } @@ -46,7 +44,7 @@ func DeriveCLIConfig(reqs *AggregatedRequirements) *DerivedCLIConfig { // MergeCLIConfig merges derived config with explicit forge.yaml config. // Explicit non-nil slices override derived values entirely. // Nil/empty explicit slices allow derived values through. -func MergeCLIConfig(explicit, derived *DerivedCLIConfig) *DerivedCLIConfig { +func MergeCLIConfig(explicit, derived *contract.DerivedCLIConfig) *contract.DerivedCLIConfig { if derived == nil { return explicit } @@ -54,7 +52,7 @@ func MergeCLIConfig(explicit, derived *DerivedCLIConfig) *DerivedCLIConfig { return derived } - merged := &DerivedCLIConfig{} + merged := &contract.DerivedCLIConfig{} if len(explicit.AllowedBinaries) > 0 { merged.AllowedBinaries = explicit.AllowedBinaries diff --git a/forge-core/skills/derive_test.go b/forge-skills/requirements/derive_test.go similarity index 84% rename from forge-core/skills/derive_test.go rename to forge-skills/requirements/derive_test.go index 024cb0e..bbf9c52 100644 --- a/forge-core/skills/derive_test.go +++ b/forge-skills/requirements/derive_test.go @@ -1,11 +1,13 @@ -package skills +package requirements import ( "testing" + + "github.com/initializ/forge/forge-skills/contract" ) func TestDerive_Basic(t *testing.T) { - reqs := &AggregatedRequirements{ + reqs := &contract.AggregatedRequirements{ Bins: []string{"curl", "jq"}, EnvRequired: []string{"API_KEY"}, EnvOneOf: [][]string{{"OPENAI_KEY", "ANTHROPIC_KEY"}}, @@ -22,7 +24,7 @@ func TestDerive_Basic(t *testing.T) { } // EnvPassthrough should be union of all env vars, sorted - // API_KEY, ANTHROPIC_KEY, DEBUG, OPENAI_KEY + // ANTHROPIC_KEY, API_KEY, DEBUG, OPENAI_KEY if len(cfg.EnvPassthrough) != 4 { t.Fatalf("EnvPassthrough = %v, want 4 items", cfg.EnvPassthrough) } @@ -35,11 +37,11 @@ func TestDerive_Basic(t *testing.T) { } func TestMerge_ExplicitOverrides(t *testing.T) { - explicit := &DerivedCLIConfig{ + explicit := &contract.DerivedCLIConfig{ AllowedBinaries: []string{"python"}, EnvPassthrough: []string{"CUSTOM_VAR"}, } - derived := &DerivedCLIConfig{ + derived := &contract.DerivedCLIConfig{ AllowedBinaries: []string{"curl", "jq"}, EnvPassthrough: []string{"API_KEY"}, } @@ -54,8 +56,8 @@ func TestMerge_ExplicitOverrides(t *testing.T) { } func TestMerge_NilAllowsDerived(t *testing.T) { - explicit := &DerivedCLIConfig{} // empty slices (nil) - derived := &DerivedCLIConfig{ + explicit := &contract.DerivedCLIConfig{} // empty slices (nil) + derived := &contract.DerivedCLIConfig{ AllowedBinaries: []string{"curl", "jq"}, EnvPassthrough: []string{"API_KEY"}, } diff --git a/forge-core/skills/requirements.go b/forge-skills/requirements/requirements.go similarity index 71% rename from forge-core/skills/requirements.go rename to forge-skills/requirements/requirements.go index abd6f46..70b4cd4 100644 --- a/forge-core/skills/requirements.go +++ b/forge-skills/requirements/requirements.go @@ -1,14 +1,11 @@ -package skills +// Package requirements provides aggregation and derivation of skill requirements. +package requirements -import "sort" +import ( + "sort" -// AggregatedRequirements is the union of all skill requirements. -type AggregatedRequirements struct { - Bins []string // union of all bins, deduplicated, sorted - EnvRequired []string // union of required vars (promoted from optional if needed) - EnvOneOf [][]string // separate groups per skill (not merged across skills) - EnvOptional []string // union of optional vars minus those promoted to required -} + "github.com/initializ/forge/forge-skills/contract" +) // AggregateRequirements merges requirements from all entries that have ForgeReqs set. // @@ -16,7 +13,7 @@ type AggregatedRequirements struct { // - var in both required (skill A) and optional (skill B) โ†’ required // - var in one_of (skill A) and required (skill B) โ†’ stays in required (group still exists) // - one_of groups kept separate per skill -func AggregateRequirements(entries []SkillEntry) *AggregatedRequirements { +func AggregateRequirements(entries []contract.SkillEntry) *contract.AggregatedRequirements { binSet := make(map[string]bool) reqSet := make(map[string]bool) optSet := make(map[string]bool) @@ -49,7 +46,7 @@ func AggregateRequirements(entries []SkillEntry) *AggregatedRequirements { } } - agg := &AggregatedRequirements{ + agg := &contract.AggregatedRequirements{ Bins: sortedKeys(binSet), EnvOneOf: oneOfGroups, } diff --git a/forge-core/skills/requirements_test.go b/forge-skills/requirements/requirements_test.go similarity index 77% rename from forge-core/skills/requirements_test.go rename to forge-skills/requirements/requirements_test.go index 6d0d67e..6aa1faf 100644 --- a/forge-core/skills/requirements_test.go +++ b/forge-skills/requirements/requirements_test.go @@ -1,16 +1,18 @@ -package skills +package requirements import ( "testing" + + "github.com/initializ/forge/forge-skills/contract" ) func TestAggregate_SingleSkill(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ { - Name: "summarize", - ForgeReqs: &SkillRequirements{ + Name: "github", + ForgeReqs: &contract.SkillRequirements{ Bins: []string{"curl", "jq"}, - Env: &EnvRequirements{ + Env: &contract.EnvRequirements{ Required: []string{"API_KEY"}, Optional: []string{"TIMEOUT"}, }, @@ -34,14 +36,14 @@ func TestAggregate_SingleSkill(t *testing.T) { } func TestAggregate_MultiSkill_BinsUnion(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ { Name: "a", - ForgeReqs: &SkillRequirements{Bins: []string{"curl", "jq"}}, + ForgeReqs: &contract.SkillRequirements{Bins: []string{"curl", "jq"}}, }, { Name: "b", - ForgeReqs: &SkillRequirements{Bins: []string{"jq", "python"}}, + ForgeReqs: &contract.SkillRequirements{Bins: []string{"jq", "python"}}, }, } @@ -49,7 +51,6 @@ func TestAggregate_MultiSkill_BinsUnion(t *testing.T) { if len(reqs.Bins) != 3 { t.Errorf("expected 3 bins, got %d: %v", len(reqs.Bins), reqs.Bins) } - // Should be sorted and deduplicated expected := []string{"curl", "jq", "python"} for i, b := range expected { if reqs.Bins[i] != b { @@ -59,19 +60,19 @@ func TestAggregate_MultiSkill_BinsUnion(t *testing.T) { } func TestAggregate_PromotionOptionalToRequired(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ { Name: "a", - ForgeReqs: &SkillRequirements{ - Env: &EnvRequirements{ + ForgeReqs: &contract.SkillRequirements{ + Env: &contract.EnvRequirements{ Required: []string{"API_KEY"}, }, }, }, { Name: "b", - ForgeReqs: &SkillRequirements{ - Env: &EnvRequirements{ + ForgeReqs: &contract.SkillRequirements{ + Env: &contract.EnvRequirements{ Optional: []string{"API_KEY", "DEBUG"}, }, }, @@ -79,30 +80,28 @@ func TestAggregate_PromotionOptionalToRequired(t *testing.T) { } reqs := AggregateRequirements(entries) - // API_KEY should be promoted to required (from optional in skill B) if len(reqs.EnvRequired) != 1 || reqs.EnvRequired[0] != "API_KEY" { t.Errorf("EnvRequired = %v, want [API_KEY]", reqs.EnvRequired) } - // DEBUG should remain optional if len(reqs.EnvOptional) != 1 || reqs.EnvOptional[0] != "DEBUG" { t.Errorf("EnvOptional = %v, want [DEBUG]", reqs.EnvOptional) } } func TestAggregate_OneOfKeptSeparate(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ { Name: "a", - ForgeReqs: &SkillRequirements{ - Env: &EnvRequirements{ + ForgeReqs: &contract.SkillRequirements{ + Env: &contract.EnvRequirements{ OneOf: []string{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"}, }, }, }, { Name: "b", - ForgeReqs: &SkillRequirements{ - Env: &EnvRequirements{ + ForgeReqs: &contract.SkillRequirements{ + Env: &contract.EnvRequirements{ OneOf: []string{"GCP_KEY", "AWS_KEY"}, }, }, @@ -122,7 +121,7 @@ func TestAggregate_OneOfKeptSeparate(t *testing.T) { } func TestAggregate_NoRequirements(t *testing.T) { - entries := []SkillEntry{ + entries := []contract.SkillEntry{ {Name: "a"}, {Name: "b"}, } diff --git a/forge-core/skills/env_resolver.go b/forge-skills/resolver/env_resolver.go similarity index 65% rename from forge-core/skills/env_resolver.go rename to forge-skills/resolver/env_resolver.go index d3135ca..7cc4a87 100644 --- a/forge-core/skills/env_resolver.go +++ b/forge-skills/resolver/env_resolver.go @@ -1,27 +1,13 @@ -package skills +// Package resolver checks env var and binary availability against skill requirements. +package resolver import ( "fmt" "os/exec" -) - -// EnvSource describes where an environment variable was found. -type EnvSource string -const ( - EnvSourceOS EnvSource = "environment" - EnvSourceDotEnv EnvSource = "dotenv" - EnvSourceConfig EnvSource = "config" - EnvSourceMissing EnvSource = "missing" + "github.com/initializ/forge/forge-skills/contract" ) -// ValidationDiagnostic represents a single validation finding. -type ValidationDiagnostic struct { - Level string // "error", "warning", "info" - Message string - Var string -} - // EnvResolver checks env var availability across multiple sources. type EnvResolver struct { osEnv map[string]string @@ -45,17 +31,17 @@ func NewEnvResolver(osEnv, dotEnv, cfgEnv map[string]string) *EnvResolver { // Resolve checks all requirements against available env sources. // Returns diagnostics: error for missing required/one_of, warning for missing optional. -func (r *EnvResolver) Resolve(reqs *AggregatedRequirements) []ValidationDiagnostic { +func (r *EnvResolver) Resolve(reqs *contract.AggregatedRequirements) []contract.ValidationDiagnostic { if reqs == nil { return nil } - var diags []ValidationDiagnostic + var diags []contract.ValidationDiagnostic // Check required vars for _, v := range reqs.EnvRequired { src := r.lookup(v) - if src == EnvSourceMissing { - diags = append(diags, ValidationDiagnostic{ + if src == contract.EnvSourceMissing { + diags = append(diags, contract.ValidationDiagnostic{ Level: "error", Message: fmt.Sprintf("required env var %s is not set", v), Var: v, @@ -67,13 +53,13 @@ func (r *EnvResolver) Resolve(reqs *AggregatedRequirements) []ValidationDiagnost for _, group := range reqs.EnvOneOf { found := false for _, v := range group { - if r.lookup(v) != EnvSourceMissing { + if r.lookup(v) != contract.EnvSourceMissing { found = true break } } if !found { - diags = append(diags, ValidationDiagnostic{ + diags = append(diags, contract.ValidationDiagnostic{ Level: "error", Message: fmt.Sprintf("at least one of [%s] must be set", joinVars(group)), Var: group[0], @@ -84,8 +70,8 @@ func (r *EnvResolver) Resolve(reqs *AggregatedRequirements) []ValidationDiagnost // Check optional vars for _, v := range reqs.EnvOptional { src := r.lookup(v) - if src == EnvSourceMissing { - diags = append(diags, ValidationDiagnostic{ + if src == contract.EnvSourceMissing { + diags = append(diags, contract.ValidationDiagnostic{ Level: "warning", Message: fmt.Sprintf("optional env var %s is not set", v), Var: v, @@ -97,25 +83,25 @@ func (r *EnvResolver) Resolve(reqs *AggregatedRequirements) []ValidationDiagnost } // lookup checks for a var across all sources in priority order. -func (r *EnvResolver) lookup(key string) EnvSource { +func (r *EnvResolver) lookup(key string) contract.EnvSource { if _, ok := r.osEnv[key]; ok { - return EnvSourceOS + return contract.EnvSourceOS } if _, ok := r.dotEnv[key]; ok { - return EnvSourceDotEnv + return contract.EnvSourceDotEnv } if _, ok := r.cfgEnv[key]; ok { - return EnvSourceConfig + return contract.EnvSourceConfig } - return EnvSourceMissing + return contract.EnvSourceMissing } // BinDiagnostics checks binary availability via exec.LookPath. -func BinDiagnostics(bins []string) []ValidationDiagnostic { - var diags []ValidationDiagnostic +func BinDiagnostics(bins []string) []contract.ValidationDiagnostic { + var diags []contract.ValidationDiagnostic for _, bin := range bins { if _, err := exec.LookPath(bin); err != nil { - diags = append(diags, ValidationDiagnostic{ + diags = append(diags, contract.ValidationDiagnostic{ Level: "warning", Message: fmt.Sprintf("binary %q not found in PATH", bin), Var: bin, diff --git a/forge-core/skills/env_resolver_test.go b/forge-skills/resolver/env_resolver_test.go similarity index 87% rename from forge-core/skills/env_resolver_test.go rename to forge-skills/resolver/env_resolver_test.go index af61eaa..5d7089f 100644 --- a/forge-core/skills/env_resolver_test.go +++ b/forge-skills/resolver/env_resolver_test.go @@ -1,7 +1,9 @@ -package skills +package resolver import ( "testing" + + "github.com/initializ/forge/forge-skills/contract" ) func TestResolve_AllFromOS(t *testing.T) { @@ -10,7 +12,7 @@ func TestResolve_AllFromOS(t *testing.T) { "TIMEOUT": "30", } resolver := NewEnvResolver(osEnv, nil, nil) - reqs := &AggregatedRequirements{ + reqs := &contract.AggregatedRequirements{ EnvRequired: []string{"API_KEY"}, EnvOptional: []string{"TIMEOUT"}, } @@ -27,7 +29,7 @@ func TestResolve_FallbackToDotEnv(t *testing.T) { "API_KEY": "from-dotenv", } resolver := NewEnvResolver(osEnv, dotEnv, nil) - reqs := &AggregatedRequirements{ + reqs := &contract.AggregatedRequirements{ EnvRequired: []string{"API_KEY"}, } @@ -39,7 +41,7 @@ func TestResolve_FallbackToDotEnv(t *testing.T) { func TestResolve_MissingRequired_Error(t *testing.T) { resolver := NewEnvResolver(nil, nil, nil) - reqs := &AggregatedRequirements{ + reqs := &contract.AggregatedRequirements{ EnvRequired: []string{"API_KEY"}, } @@ -57,7 +59,7 @@ func TestResolve_MissingRequired_Error(t *testing.T) { func TestResolve_MissingOneOf_Error(t *testing.T) { resolver := NewEnvResolver(nil, nil, nil) - reqs := &AggregatedRequirements{ + reqs := &contract.AggregatedRequirements{ EnvOneOf: [][]string{{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"}}, } @@ -72,7 +74,7 @@ func TestResolve_MissingOneOf_Error(t *testing.T) { func TestResolve_MissingOptional_Warning(t *testing.T) { resolver := NewEnvResolver(nil, nil, nil) - reqs := &AggregatedRequirements{ + reqs := &contract.AggregatedRequirements{ EnvOptional: []string{"DEBUG"}, } @@ -90,7 +92,7 @@ func TestResolve_OneOfPartialSatisfied(t *testing.T) { "ANTHROPIC_API_KEY": "sk-ant-123", } resolver := NewEnvResolver(osEnv, nil, nil) - reqs := &AggregatedRequirements{ + reqs := &contract.AggregatedRequirements{ EnvOneOf: [][]string{{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"}}, } diff --git a/forge-skills/trust/integrity.go b/forge-skills/trust/integrity.go new file mode 100644 index 0000000..c7f05bd --- /dev/null +++ b/forge-skills/trust/integrity.go @@ -0,0 +1,129 @@ +package trust + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + + "github.com/initializ/forge/forge-skills/contract" +) + +// Manifest stores checksums for all skills in a registry. +type Manifest struct { + Version string `json:"version"` + Checksums map[string]string `json:"checksums"` // skill name -> "sha256:" +} + +// IntegrityViolation describes a checksum mismatch or missing entry. +type IntegrityViolation struct { + SkillName string `json:"skill_name"` + Expected string `json:"expected"` + Actual string `json:"actual"` + Reason string `json:"reason"` // "mismatch", "missing_in_manifest", "missing_in_registry" +} + +// ComputeChecksum returns the SHA-256 checksum of content as "sha256:". +func ComputeChecksum(content []byte) string { + h := sha256.Sum256(content) + return fmt.Sprintf("sha256:%x", h) +} + +// VerifyChecksum checks whether content matches the expected checksum string. +func VerifyChecksum(content []byte, expected string) bool { + return ComputeChecksum(content) == expected +} + +// GenerateManifest creates a Manifest from all skills in the registry. +func GenerateManifest(registry contract.SkillRegistry) (*Manifest, error) { + skills, err := registry.List() + if err != nil { + return nil, fmt.Errorf("listing skills: %w", err) + } + + m := &Manifest{ + Version: "1", + Checksums: make(map[string]string, len(skills)), + } + + for _, sd := range skills { + content, err := registry.LoadContent(sd.Name) + if err != nil { + return nil, fmt.Errorf("loading content for %q: %w", sd.Name, err) + } + m.Checksums[sd.Name] = ComputeChecksum(content) + } + + return m, nil +} + +// VerifyManifest checks all skills in the registry against the manifest. +// It returns a list of violations (empty if everything matches). +func VerifyManifest(registry contract.SkillRegistry, manifest *Manifest) []IntegrityViolation { + var violations []IntegrityViolation + + skills, err := registry.List() + if err != nil { + return []IntegrityViolation{{Reason: "registry_error"}} + } + + registryNames := make(map[string]bool, len(skills)) + for _, sd := range skills { + registryNames[sd.Name] = true + + expected, inManifest := manifest.Checksums[sd.Name] + if !inManifest { + violations = append(violations, IntegrityViolation{ + SkillName: sd.Name, + Reason: "missing_in_manifest", + }) + continue + } + + content, err := registry.LoadContent(sd.Name) + if err != nil { + violations = append(violations, IntegrityViolation{ + SkillName: sd.Name, + Expected: expected, + Reason: "content_unavailable", + }) + continue + } + + actual := ComputeChecksum(content) + if actual != expected { + violations = append(violations, IntegrityViolation{ + SkillName: sd.Name, + Expected: expected, + Actual: actual, + Reason: "mismatch", + }) + } + } + + // Check for entries in manifest not in registry + for name := range manifest.Checksums { + if !registryNames[name] { + violations = append(violations, IntegrityViolation{ + SkillName: name, + Expected: manifest.Checksums[name], + Reason: "missing_in_registry", + }) + } + } + + return violations +} + +// MarshalManifest serializes a manifest to JSON. +func MarshalManifest(m *Manifest) ([]byte, error) { + return json.MarshalIndent(m, "", " ") +} + +// UnmarshalManifest deserializes a manifest from JSON. +func UnmarshalManifest(data []byte) (*Manifest, error) { + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} diff --git a/forge-skills/trust/integrity_test.go b/forge-skills/trust/integrity_test.go new file mode 100644 index 0000000..695b218 --- /dev/null +++ b/forge-skills/trust/integrity_test.go @@ -0,0 +1,221 @@ +package trust + +import ( + "testing" + + "github.com/initializ/forge/forge-skills/contract" +) + +func TestComputeChecksum(t *testing.T) { + content := []byte("hello world") + cs := ComputeChecksum(content) + + if cs == "" { + t.Fatal("checksum is empty") + } + if len(cs) < len("sha256:") { + t.Fatalf("checksum too short: %s", cs) + } + if cs[:7] != "sha256:" { + t.Fatalf("checksum prefix wrong: %s", cs) + } +} + +func TestVerifyChecksum(t *testing.T) { + content := []byte("test content") + cs := ComputeChecksum(content) + + if !VerifyChecksum(content, cs) { + t.Fatal("checksum verification failed for matching content") + } + + if VerifyChecksum([]byte("tampered"), cs) { + t.Fatal("checksum verification passed for tampered content") + } +} + +func TestChecksumDeterministic(t *testing.T) { + content := []byte("deterministic test") + cs1 := ComputeChecksum(content) + cs2 := ComputeChecksum(content) + + if cs1 != cs2 { + t.Fatalf("checksum not deterministic: %s vs %s", cs1, cs2) + } +} + +// mockRegistry implements contract.SkillRegistry for testing. +type mockRegistry struct { + skills []contract.SkillDescriptor + content map[string][]byte + scripts map[string]bool +} + +func (m *mockRegistry) List() ([]contract.SkillDescriptor, error) { + return m.skills, nil +} + +func (m *mockRegistry) Get(name string) *contract.SkillDescriptor { + for i := range m.skills { + if m.skills[i].Name == name { + return &m.skills[i] + } + } + return nil +} + +func (m *mockRegistry) LoadContent(name string) ([]byte, error) { + if c, ok := m.content[name]; ok { + return c, nil + } + return nil, nil +} + +func (m *mockRegistry) HasScript(name string) bool { + return m.scripts[name] +} + +func (m *mockRegistry) LoadScript(name string) ([]byte, error) { + return nil, nil +} + +func TestGenerateManifest(t *testing.T) { + reg := &mockRegistry{ + skills: []contract.SkillDescriptor{ + {Name: "github"}, + {Name: "weather"}, + }, + content: map[string][]byte{ + "github": []byte("# GitHub skill"), + "weather": []byte("# Weather skill"), + }, + } + + manifest, err := GenerateManifest(reg) + if err != nil { + t.Fatalf("GenerateManifest failed: %v", err) + } + + if len(manifest.Checksums) != 2 { + t.Fatalf("expected 2 checksums, got %d", len(manifest.Checksums)) + } + + if manifest.Checksums["github"] == "" { + t.Fatal("github checksum is empty") + } + if manifest.Checksums["weather"] == "" { + t.Fatal("weather checksum is empty") + } +} + +func TestVerifyManifest_Clean(t *testing.T) { + reg := &mockRegistry{ + skills: []contract.SkillDescriptor{ + {Name: "github"}, + }, + content: map[string][]byte{ + "github": []byte("# GitHub skill"), + }, + } + + manifest, _ := GenerateManifest(reg) + violations := VerifyManifest(reg, manifest) + + if len(violations) != 0 { + t.Fatalf("expected 0 violations, got %d: %+v", len(violations), violations) + } +} + +func TestVerifyManifest_Tampered(t *testing.T) { + content := []byte("# GitHub skill") + reg := &mockRegistry{ + skills: []contract.SkillDescriptor{{Name: "github"}}, + content: map[string][]byte{"github": content}, + } + + manifest, _ := GenerateManifest(reg) + + // Tamper the content + reg.content["github"] = []byte("# Tampered skill") + + violations := VerifyManifest(reg, manifest) + if len(violations) != 1 { + t.Fatalf("expected 1 violation, got %d", len(violations)) + } + if violations[0].Reason != "mismatch" { + t.Fatalf("expected mismatch reason, got %q", violations[0].Reason) + } +} + +func TestVerifyManifest_MissingInManifest(t *testing.T) { + reg := &mockRegistry{ + skills: []contract.SkillDescriptor{{Name: "github"}, {Name: "new-skill"}}, + content: map[string][]byte{"github": []byte("x"), "new-skill": []byte("y")}, + } + + manifest := &Manifest{ + Version: "1", + Checksums: map[string]string{"github": ComputeChecksum([]byte("x"))}, + } + + violations := VerifyManifest(reg, manifest) + found := false + for _, v := range violations { + if v.SkillName == "new-skill" && v.Reason == "missing_in_manifest" { + found = true + } + } + if !found { + t.Fatal("expected missing_in_manifest violation for new-skill") + } +} + +func TestVerifyManifest_MissingInRegistry(t *testing.T) { + reg := &mockRegistry{ + skills: []contract.SkillDescriptor{{Name: "github"}}, + content: map[string][]byte{"github": []byte("x")}, + } + + manifest := &Manifest{ + Version: "1", + Checksums: map[string]string{ + "github": ComputeChecksum([]byte("x")), + "removed": "sha256:abc", + }, + } + + violations := VerifyManifest(reg, manifest) + found := false + for _, v := range violations { + if v.SkillName == "removed" && v.Reason == "missing_in_registry" { + found = true + } + } + if !found { + t.Fatal("expected missing_in_registry violation for removed skill") + } +} + +func TestMarshalUnmarshalManifest(t *testing.T) { + manifest := &Manifest{ + Version: "1", + Checksums: map[string]string{"github": "sha256:abc123"}, + } + + data, err := MarshalManifest(manifest) + if err != nil { + t.Fatalf("MarshalManifest failed: %v", err) + } + + parsed, err := UnmarshalManifest(data) + if err != nil { + t.Fatalf("UnmarshalManifest failed: %v", err) + } + + if parsed.Version != manifest.Version { + t.Fatalf("version mismatch: %s vs %s", parsed.Version, manifest.Version) + } + if parsed.Checksums["github"] != manifest.Checksums["github"] { + t.Fatal("checksum mismatch after round-trip") + } +} diff --git a/forge-skills/trust/keyring.go b/forge-skills/trust/keyring.go new file mode 100644 index 0000000..d1d1e76 --- /dev/null +++ b/forge-skills/trust/keyring.go @@ -0,0 +1,100 @@ +package trust + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Keyring manages a set of trusted Ed25519 public keys. +type Keyring struct { + keys map[string]ed25519.PublicKey // keyID -> pubkey +} + +// NewKeyring creates an empty keyring. +func NewKeyring() *Keyring { + return &Keyring{keys: make(map[string]ed25519.PublicKey)} +} + +// Add registers a public key with the given ID. +func (k *Keyring) Add(keyID string, pubKey ed25519.PublicKey) { + k.keys[keyID] = pubKey +} + +// Get returns the public key for the given ID, or nil if not found. +func (k *Keyring) Get(keyID string) ed25519.PublicKey { + return k.keys[keyID] +} + +// List returns all key IDs in the keyring. +func (k *Keyring) List() []string { + ids := make([]string, 0, len(k.keys)) + for id := range k.keys { + ids = append(ids, id) + } + return ids +} + +// LoadFromDir reads all *.pub files from a directory and adds them to the keyring. +// Each file should contain a base64-encoded Ed25519 public key. +// The key ID is derived from the filename (without .pub extension). +func (k *Keyring) LoadFromDir(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil // directory doesn't exist yet, that's fine + } + return fmt.Errorf("reading key directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pub") { + continue + } + + keyID := strings.TrimSuffix(entry.Name(), ".pub") + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return fmt.Errorf("reading key %q: %w", keyID, err) + } + + // Decode base64 + pubBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data))) + if err != nil { + return fmt.Errorf("decoding key %q: %w", keyID, err) + } + + if len(pubBytes) != ed25519.PublicKeySize { + return fmt.Errorf("key %q has invalid size: %d (expected %d)", keyID, len(pubBytes), ed25519.PublicKeySize) + } + + k.keys[keyID] = ed25519.PublicKey(pubBytes) + } + + return nil +} + +// Verify tries all keys in the keyring against the content and signature. +// Returns the matching key ID and true if verified, or empty string and false. +func (k *Keyring) Verify(content, signature []byte) (keyID string, ok bool) { + for id, pubKey := range k.keys { + if VerifySkill(content, signature, pubKey) { + return id, true + } + } + return "", false +} + +// DefaultKeyring loads trusted keys from ~/.forge/trusted-keys/. +func DefaultKeyring() *Keyring { + kr := NewKeyring() + home, err := os.UserHomeDir() + if err != nil { + return kr + } + _ = kr.LoadFromDir(filepath.Join(home, ".forge", "trusted-keys")) + return kr +} diff --git a/forge-skills/trust/keyring_test.go b/forge-skills/trust/keyring_test.go new file mode 100644 index 0000000..4a81f54 --- /dev/null +++ b/forge-skills/trust/keyring_test.go @@ -0,0 +1,115 @@ +package trust + +import ( + "crypto/ed25519" + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +func TestKeyring_AddAndGet(t *testing.T) { + kr := NewKeyring() + pub, _, _ := GenerateKeyPair() + + kr.Add("test-key", pub) + + got := kr.Get("test-key") + if got == nil { + t.Fatal("key not found") + } + if !got.Equal(pub) { + t.Fatal("key mismatch") + } +} + +func TestKeyring_List(t *testing.T) { + kr := NewKeyring() + pub1, _, _ := GenerateKeyPair() + pub2, _, _ := GenerateKeyPair() + + kr.Add("key1", pub1) + kr.Add("key2", pub2) + + ids := kr.List() + if len(ids) != 2 { + t.Fatalf("expected 2 keys, got %d", len(ids)) + } +} + +func TestKeyring_Verify(t *testing.T) { + kr := NewKeyring() + pub, priv, _ := GenerateKeyPair() + kr.Add("signer", pub) + + content := []byte("signed content") + sig, _ := SignSkill(content, priv) + + keyID, ok := kr.Verify(content, sig) + if !ok { + t.Fatal("keyring verification failed") + } + if keyID != "signer" { + t.Fatalf("expected keyID 'signer', got %q", keyID) + } +} + +func TestKeyring_VerifyNoMatch(t *testing.T) { + kr := NewKeyring() + pub1, _, _ := GenerateKeyPair() + kr.Add("key1", pub1) + + _, priv2, _ := GenerateKeyPair() + content := []byte("content") + sig, _ := SignSkill(content, priv2) + + _, ok := kr.Verify(content, sig) + if ok { + t.Fatal("keyring should not verify with wrong key") + } +} + +func TestKeyring_LoadFromDir(t *testing.T) { + dir := t.TempDir() + + // Create a valid key file + pub, _, _ := GenerateKeyPair() + pubB64 := base64.StdEncoding.EncodeToString(pub) + if err := os.WriteFile(filepath.Join(dir, "test.pub"), []byte(pubB64), 0644); err != nil { + t.Fatal(err) + } + + kr := NewKeyring() + if err := kr.LoadFromDir(dir); err != nil { + t.Fatalf("LoadFromDir failed: %v", err) + } + + got := kr.Get("test") + if got == nil { + t.Fatal("key not loaded") + } + if len(got) != ed25519.PublicKeySize { + t.Fatalf("loaded key wrong size: %d", len(got)) + } +} + +func TestKeyring_LoadFromDir_NonExistent(t *testing.T) { + kr := NewKeyring() + err := kr.LoadFromDir("/nonexistent/path") + if err != nil { + t.Fatalf("should not error on missing directory: %v", err) + } +} + +func TestKeyring_LoadFromDir_InvalidKey(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "bad.pub"), []byte("not-base64!!!"), 0644); err != nil { + t.Fatal(err) + } + + kr := NewKeyring() + err := kr.LoadFromDir(dir) + if err == nil { + t.Fatal("expected error for invalid key") + } +} diff --git a/forge-skills/trust/signature.go b/forge-skills/trust/signature.go new file mode 100644 index 0000000..b2d6dab --- /dev/null +++ b/forge-skills/trust/signature.go @@ -0,0 +1,42 @@ +package trust + +import ( + "crypto/ed25519" + "crypto/rand" + "fmt" +) + +// GenerateKeyPair creates a new Ed25519 key pair. +func GenerateKeyPair() (ed25519.PublicKey, ed25519.PrivateKey, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generating key pair: %w", err) + } + return pub, priv, nil +} + +// Sign produces an Ed25519 signature of content. +func Sign(content []byte, privateKey ed25519.PrivateKey) []byte { + return ed25519.Sign(privateKey, content) +} + +// Verify checks an Ed25519 signature of content. +func Verify(content []byte, signature []byte, publicKey ed25519.PublicKey) bool { + return ed25519.Verify(publicKey, content, signature) +} + +// SignSkill produces a detached signature for skill content. +func SignSkill(skillContent []byte, privateKey ed25519.PrivateKey) ([]byte, error) { + if len(privateKey) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid private key size: %d", len(privateKey)) + } + return Sign(skillContent, privateKey), nil +} + +// VerifySkill checks a detached signature for skill content. +func VerifySkill(skillContent, signature []byte, publicKey ed25519.PublicKey) bool { + if len(publicKey) != ed25519.PublicKeySize || len(signature) != ed25519.SignatureSize { + return false + } + return Verify(skillContent, signature, publicKey) +} diff --git a/forge-skills/trust/signature_test.go b/forge-skills/trust/signature_test.go new file mode 100644 index 0000000..9d96843 --- /dev/null +++ b/forge-skills/trust/signature_test.go @@ -0,0 +1,74 @@ +package trust + +import ( + "crypto/ed25519" + "testing" +) + +func TestGenerateKeyPair(t *testing.T) { + pub, priv, err := GenerateKeyPair() + if err != nil { + t.Fatalf("GenerateKeyPair failed: %v", err) + } + if len(pub) != ed25519.PublicKeySize { + t.Fatalf("public key wrong size: %d", len(pub)) + } + if len(priv) != ed25519.PrivateKeySize { + t.Fatalf("private key wrong size: %d", len(priv)) + } +} + +func TestSignAndVerify(t *testing.T) { + pub, priv, err := GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + content := []byte("skill content to sign") + sig := Sign(content, priv) + + if !Verify(content, sig, pub) { + t.Fatal("signature verification failed") + } + + // Tampered content should fail + if Verify([]byte("tampered"), sig, pub) { + t.Fatal("verification passed for tampered content") + } +} + +func TestSignSkillAndVerifySkill(t *testing.T) { + pub, priv, err := GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + + content := []byte("# My Skill\nSome content here") + sig, err := SignSkill(content, priv) + if err != nil { + t.Fatalf("SignSkill failed: %v", err) + } + + if !VerifySkill(content, sig, pub) { + t.Fatal("VerifySkill failed for valid signature") + } + + // Wrong key should fail + pub2, _, _ := GenerateKeyPair() + if VerifySkill(content, sig, pub2) { + t.Fatal("VerifySkill passed for wrong key") + } +} + +func TestSignSkill_InvalidKey(t *testing.T) { + _, err := SignSkill([]byte("content"), []byte("short")) + if err == nil { + t.Fatal("expected error for invalid key") + } +} + +func TestVerifySkill_InvalidInputs(t *testing.T) { + if VerifySkill([]byte("c"), []byte("short"), []byte("short")) { + t.Fatal("should fail with invalid inputs") + } +} diff --git a/forge-skills/trust/types.go b/forge-skills/trust/types.go new file mode 100644 index 0000000..01cd226 --- /dev/null +++ b/forge-skills/trust/types.go @@ -0,0 +1,42 @@ +// Package trust provides integrity verification, signature handling, and trust +// policy enforcement for forge skills. +package trust + +import "github.com/initializ/forge/forge-skills/contract" + +// TrustPolicy defines minimum trust requirements for skill loading. +type TrustPolicy struct { + MinTrustLevel contract.TrustLevel `yaml:"min_trust_level" json:"min_trust_level"` + RequireChecksum bool `yaml:"require_checksum" json:"require_checksum"` + RequireSignature bool `yaml:"require_signature" json:"require_signature"` +} + +// DefaultTrustPolicy returns a policy that accepts local skills without signatures. +func DefaultTrustPolicy() TrustPolicy { + return TrustPolicy{ + MinTrustLevel: contract.TrustLocal, + RequireChecksum: false, + RequireSignature: false, + } +} + +// Accepts reports whether the given trust level satisfies the policy. +func (p TrustPolicy) Accepts(level contract.TrustLevel) bool { + return trustOrd(level) >= trustOrd(p.MinTrustLevel) +} + +// trustOrd returns a numeric ordering for trust levels (higher = more trusted). +func trustOrd(t contract.TrustLevel) int { + switch t { + case contract.TrustBuiltin: + return 3 + case contract.TrustVerified: + return 2 + case contract.TrustLocal: + return 1 + case contract.TrustUntrusted: + return 0 + default: + return -1 + } +} diff --git a/forge.yaml.example b/forge.yaml.example index a206fca..b30e47c 100644 --- a/forge.yaml.example +++ b/forge.yaml.example @@ -31,7 +31,7 @@ tools: # Skills definition file skills: - path: skills.md + path: SKILL.md # Communication channels the agent supports channels: diff --git a/go.work b/go.work index 82d8d5f..4ec51da 100644 --- a/go.work +++ b/go.work @@ -4,4 +4,5 @@ use ( ./forge-core ./forge-cli ./forge-plugins + ./forge-skills )