Skip to content

Commit cece998

Browse files
author
Gemini CLI
committed
feat: complete Phase 9 - skill registry and signature verification
1 parent 68cd182 commit cece998

File tree

5 files changed

+284
-17
lines changed

5 files changed

+284
-17
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Check the immutable log of actions:
8383
- [x] **Phase 6: Audit** - Hash-chained logging
8484
- [x] **Phase 7: Integration** - OpenClaw Protocol Adapter
8585
- [x] **Phase 8: Network Control** - Egress filtering proxy
86-
- [ ] **Phase 9: Registry** - Signed skill distribution
86+
- [x] **Phase 9: Registry** - Signed skill distribution
8787

8888
## 🤝 Contributing
8989

cmd/aegisclaw/main.go

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func runCmd() *cobra.Command {
6565
Long: "Launches the AegisClaw runtime with the configured agent and policies.",
6666
RunE: func(cmd *cobra.Command, args []string) error {
6767
fmt.Println("🦅 AegisClaw runtime starting...")
68-
68+
6969
cfgDir, err := config.DefaultConfigDir()
7070
if err != nil {
7171
return err
@@ -79,15 +79,15 @@ func runCmd() *cobra.Command {
7979

8080
fmt.Printf("🧩 Loaded %d skills\n", len(manifests))
8181
fmt.Println("🤖 Agent is ready. Type 'help' for commands or 'exit' to quit.")
82-
82+
8383
reader := bufio.NewReader(os.Stdin)
84-
84+
8585
// Simple REPL for now
8686
for {
8787
fmt.Print("> ")
8888
input, _ := reader.ReadString('\n')
8989
input = strings.TrimSpace(input)
90-
90+
9191
switch input {
9292
case "exit", "quit":
9393
fmt.Println("👋 Goodbye!")
@@ -112,7 +112,7 @@ func runCmd() *cobra.Command {
112112
parts := strings.Fields(input)
113113
if len(parts) > 0 {
114114
skillName := parts[0]
115-
115+
116116
// Find matching manifest
117117
var targetManifest *skill.Manifest
118118
for _, m := range manifests {
@@ -129,7 +129,7 @@ func runCmd() *cobra.Command {
129129
}
130130
cmdName := parts[1]
131131
args := parts[2:]
132-
132+
133133
if _, err := agent.ExecuteSkill(cmd.Context(), targetManifest, cmdName, args); err != nil {
134134
fmt.Printf("❌ Execution failed: %v\n", err)
135135
}
@@ -443,6 +443,72 @@ func skillsCmd() *cobra.Command {
443443
},
444444
})
445445

446+
cmd.AddCommand(&cobra.Command{
447+
Use: "search [QUERY]",
448+
Short: "Search for skills in the registry",
449+
RunE: func(cmd *cobra.Command, args []string) error {
450+
cfg, err := config.LoadDefault()
451+
if err != nil {
452+
return fmt.Errorf("failed to load configuration (run 'init' first): %w", err)
453+
}
454+
455+
if cfg.Registry.URL == "" {
456+
fmt.Println("⚠️ No registry URL configured. Please update ~/.aegisclaw/config.yaml")
457+
return nil
458+
}
459+
460+
fmt.Printf("🔍 Searching registry: %s\n", cfg.Registry.URL)
461+
index, err := skill.SearchRegistry(cfg.Registry.URL)
462+
if err != nil {
463+
return err
464+
}
465+
466+
query := ""
467+
if len(args) > 0 {
468+
query = strings.ToLower(args[0])
469+
}
470+
471+
fmt.Printf("🧩 Available Skills in '%s':\n", index.RegistryName)
472+
found := false
473+
for _, s := range index.Skills {
474+
if query == "" || strings.Contains(strings.ToLower(s.Name), query) || strings.Contains(strings.ToLower(s.Description), query) {
475+
fmt.Printf(" • %-15s v%-8s %s\n", s.Name, s.Version, s.Description)
476+
found = true
477+
}
478+
}
479+
480+
if !found {
481+
fmt.Println(" (No skills matched your search)")
482+
}
483+
484+
return nil
485+
},
486+
})
487+
488+
cmd.AddCommand(&cobra.Command{
489+
Use: "add [SKILL_NAME]",
490+
Short: "Install a signed skill from the registry",
491+
Args: cobra.ExactArgs(1),
492+
RunE: func(cmd *cobra.Command, args []string) error {
493+
skillName := args[0]
494+
495+
cfg, err := config.LoadDefault()
496+
if err != nil {
497+
return fmt.Errorf("failed to load configuration (run 'init' first): %w", err)
498+
}
499+
500+
if cfg.Registry.URL == "" {
501+
return fmt.Errorf("no registry URL configured")
502+
}
503+
504+
cfgDir, _ := config.DefaultConfigDir()
505+
skillsDir := filepath.Join(cfgDir, "skills")
506+
507+
fmt.Printf("📥 Installing skill '%s'...\n", skillName)
508+
return skill.InstallSkill(skillName, skillsDir, cfg.Registry.URL, cfg.Registry.TrustKeys)
509+
},
510+
})
511+
446512
return cmd
447513
}
448514
func serveCmd() *cobra.Command {

internal/config/config.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,17 @@ import (
1111

1212
// Config represents the main AegisClaw configuration
1313
type Config struct {
14-
Version string `yaml:"version"`
15-
Agent AgentConfig `yaml:"agent"`
16-
Security SecurityConfig `yaml:"security"`
17-
Network NetworkConfig `yaml:"network"`
14+
Version string `yaml:"version"`
15+
Agent AgentConfig `yaml:"agent"`
16+
Security SecurityConfig `yaml:"security"`
17+
Network NetworkConfig `yaml:"network"`
18+
Registry RegistryConfig `yaml:"registry"`
19+
}
20+
21+
// RegistryConfig contains skill registry settings
22+
type RegistryConfig struct {
23+
URL string `yaml:"url"`
24+
TrustKeys []string `yaml:"trust_keys"` // Public keys of trusted signers
1825
}
1926

2027
// AgentConfig contains agent-specific settings

internal/skill/skill.go

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package skill
22

33
import (
4+
"crypto/ed25519"
5+
"encoding/hex"
6+
"encoding/json"
47
"fmt"
8+
"net/http"
59
"os"
610
"path/filepath"
711

@@ -10,12 +14,27 @@ import (
1014

1115
// Manifest represents a skill definition (skill.yaml)
1216
type Manifest struct {
13-
Name string `yaml:"name"`
14-
Version string `yaml:"version"`
15-
Description string `yaml:"description"`
16-
Image string `yaml:"image"`
17-
Scopes []string `yaml:"scopes"`
17+
Name string `yaml:"name"`
18+
Version string `yaml:"version"`
19+
Description string `yaml:"description"`
20+
Image string `yaml:"image"`
21+
Scopes []string `yaml:"scopes"`
1822
Commands map[string]Command `yaml:"commands"`
23+
Signature string `yaml:"signature,omitempty"` // Ed25519 signature of the manifest content
24+
}
25+
26+
// RegistrySkill represents a skill available in the registry
27+
type RegistrySkill struct {
28+
Name string `json:"name"`
29+
Version string `json:"version"`
30+
Description string `json:"description"`
31+
ManifestURL string `json:"manifest_url"`
32+
}
33+
34+
// RegistryIndex represents the registry search index
35+
type RegistryIndex struct {
36+
RegistryName string `json:"registry_name"`
37+
Skills []RegistrySkill `json:"skills"`
1938
}
2039

2140
// Command represents a runnable action within a skill
@@ -63,4 +82,120 @@ func ListSkills(dir string) ([]*Manifest, error) {
6382
}
6483
}
6584
return manifests, nil
66-
}
85+
}
86+
87+
// VerifySignature validates the manifest signature using a list of trusted public keys
88+
func (m *Manifest) VerifySignature(trustKeys []string) (bool, error) {
89+
if m.Signature == "" {
90+
return false, fmt.Errorf("manifest has no signature")
91+
}
92+
93+
sigBytes, err := hex.DecodeString(m.Signature)
94+
if err != nil {
95+
return false, fmt.Errorf("invalid signature hex: %w", err)
96+
}
97+
98+
// Create a copy without the signature for hashing/verification
99+
mCopy := *m
100+
mCopy.Signature = ""
101+
102+
// Canonical JSON serialization for hashing
103+
data, err := json.Marshal(mCopy)
104+
if err != nil {
105+
return false, fmt.Errorf("failed to marshal manifest for verification: %w", err)
106+
}
107+
108+
for _, keyStr := range trustKeys {
109+
pubKeyBytes, err := hex.DecodeString(keyStr)
110+
if err != nil {
111+
continue // Skip invalid keys
112+
}
113+
114+
if len(pubKeyBytes) != ed25519.PublicKeySize {
115+
continue
116+
}
117+
118+
pubKey := ed25519.PublicKey(pubKeyBytes)
119+
if ed25519.Verify(pubKey, data, sigBytes) {
120+
return true, nil
121+
}
122+
}
123+
124+
return false, nil
125+
}
126+
127+
// SearchRegistry fetches the registry index
128+
func SearchRegistry(registryURL string) (*RegistryIndex, error) {
129+
resp, err := http.Get(registryURL + "/index.json")
130+
if err != nil {
131+
return nil, fmt.Errorf("failed to fetch registry index: %w", err)
132+
}
133+
defer resp.Body.Close()
134+
135+
if resp.StatusCode != http.StatusOK {
136+
return nil, fmt.Errorf("registry returned error: %s", resp.Status)
137+
}
138+
139+
var index RegistryIndex
140+
if err := json.NewDecoder(resp.Body).Decode(&index); err != nil {
141+
return nil, fmt.Errorf("failed to decode registry index: %w", err)
142+
}
143+
144+
return &index, nil
145+
}
146+
147+
// InstallSkill downloads and installs a skill from the registry
148+
func InstallSkill(skillName, destDir, registryURL string, trustKeys []string) error {
149+
index, err := SearchRegistry(registryURL)
150+
if err != nil {
151+
return err
152+
}
153+
154+
var target *RegistrySkill
155+
for _, s := range index.Skills {
156+
if s.Name == skillName {
157+
target = &s
158+
break
159+
}
160+
}
161+
162+
if target == nil {
163+
return fmt.Errorf("skill '%s' not found in registry", skillName)
164+
}
165+
166+
// Fetch Manifest
167+
resp, err := http.Get(target.ManifestURL)
168+
if err != nil {
169+
return fmt.Errorf("failed to fetch manifest: %w", err)
170+
}
171+
defer resp.Body.Close()
172+
173+
var m Manifest
174+
if err := yaml.NewDecoder(resp.Body).Decode(&m); err != nil {
175+
return fmt.Errorf("failed to parse manifest from registry: %w", err)
176+
}
177+
178+
// Verify Signature
179+
valid, err := m.VerifySignature(trustKeys)
180+
if err != nil {
181+
return fmt.Errorf("signature verification error: %w", err)
182+
}
183+
if !valid {
184+
return fmt.Errorf("SECURITY ALERT: Skill signature verification failed! Possible tampering detected")
185+
}
186+
187+
// Create directory and save
188+
skillPath := filepath.Join(destDir, skillName)
189+
if err := os.MkdirAll(skillPath, 0700); err != nil {
190+
return fmt.Errorf("failed to create skill directory: %w", err)
191+
}
192+
193+
manifestPath := filepath.Join(skillPath, "skill.yaml")
194+
data, _ := yaml.Marshal(m)
195+
if err := os.WriteFile(manifestPath, data, 0600); err != nil {
196+
return fmt.Errorf("failed to save skill manifest: %w", err)
197+
}
198+
199+
fmt.Printf("✅ Successfully installed skill: %s v%s\n", m.Name, m.Version)
200+
return nil
201+
}

internal/skill/skill_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package skill
2+
3+
import (
4+
"crypto/ed25519"
5+
"encoding/hex"
6+
"encoding/json"
7+
"testing"
8+
)
9+
10+
func TestSignatureVerification(t *testing.T) {
11+
// 1. Generate keys
12+
pub, priv, err := ed25519.GenerateKey(nil)
13+
if err != nil {
14+
t.Fatal(err)
15+
}
16+
pubHex := hex.EncodeToString(pub)
17+
18+
// 2. Create manifest
19+
m := Manifest{
20+
Name: "test-skill",
21+
Version: "1.0.0",
22+
Description: "A test skill",
23+
Image: "alpine:latest",
24+
Commands: map[string]Command{"hello": {Args: []string{"echo", "hi"}}},
25+
}
26+
27+
// 3. Sign manifest
28+
data, _ := json.Marshal(m)
29+
sig := ed25519.Sign(priv, data)
30+
m.Signature = hex.EncodeToString(sig)
31+
32+
// 4. Verify (Success)
33+
valid, err := m.VerifySignature([]string{pubHex})
34+
if err != nil {
35+
t.Errorf("VerifySignature returned error: %v", err)
36+
}
37+
if !valid {
38+
t.Error("VerifySignature failed for valid signature")
39+
}
40+
41+
// 5. Verify (Failure - Wrong Key)
42+
_, priv2, _ := ed25519.GenerateKey(nil)
43+
sig2 := ed25519.Sign(priv2, data)
44+
m.Signature = hex.EncodeToString(sig2)
45+
46+
valid, _ = m.VerifySignature([]string{pubHex})
47+
if valid {
48+
t.Error("VerifySignature succeeded for wrong public key")
49+
}
50+
51+
// 6. Verify (Failure - Tampered Content)
52+
m.Signature = hex.EncodeToString(sig) // back to valid signature
53+
m.Description = "Tampered description"
54+
55+
valid, _ = m.VerifySignature([]string{pubHex})
56+
if valid {
57+
t.Error("VerifySignature succeeded for tampered content")
58+
}
59+
}

0 commit comments

Comments
 (0)