Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 \
Expand Down
8 changes: 4 additions & 4 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions forge-cli/build/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions forge-cli/build/requirements_stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions forge-cli/build/requirements_stage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"},
Expand Down
73 changes: 73 additions & 0 deletions forge-cli/build/security_stage.go
Original file line number Diff line number Diff line change
@@ -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
}
99 changes: 99 additions & 0 deletions forge-cli/build/security_stage_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
14 changes: 9 additions & 5 deletions forge-cli/build/skills_stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions forge-cli/build/skills_stage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions forge-cli/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
Loading
Loading