Files
multica/server/internal/daemon/execenv/runtime_skill_policy.go
Jiayuan Zhang 5d9295ac65 feat(agents): add per-agent runtime skill controls (#5686)
* feat(agents): add per-agent runtime skill controls

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle

Address the MUL-5101 review blockers on PR #5686:

- Rebase onto main and renumber the runtime-skill-disable migration
  202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
  collided on prefix 202 and migrations_lint_test would reject the
  duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
  disabled_runtime_skills override, mirroring the workspace-skill toggle
  in writeUpdatedAgentSkills. The realtime layer keys off this event to
  invalidate workspaceKeys.agents, so other open web/desktop/mobile
  clients now drop their stale toggle state instead of only the
  initiating tab refreshing. Reload junction-table skills before the
  broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
  disable and enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:38 +08:00

160 lines
4.3 KiB
Go

package execenv
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
const claudeRuntimeSkillSettingsFile = "claude-runtime-skill-settings.json"
// RuntimeSkillRefForEnv identifies a runtime-local skill for provider-specific
// task environment filtering. Provider and runtime are already selected by the
// task, so only the discovery root and provider-native key are needed here.
type RuntimeSkillRefForEnv struct {
Root string
Key string
Name string
Plugin string
}
func cleanRuntimeSkillKey(key string) (string, bool) {
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(key)))
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", false
}
return filepath.ToSlash(cleaned), true
}
func prepareClaudeSkillSettings(envRoot string, disabled []RuntimeSkillRefForEnv, workspaceSkills []SkillContextForEnv) (string, error) {
path := filepath.Join(envRoot, claudeRuntimeSkillSettingsFile)
if len(disabled) == 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return "", err
}
return "", nil
}
overrides := make(map[string]string)
deny := make([]string, 0, len(disabled)*2)
seenDeny := make(map[string]struct{}, len(disabled)*2)
addDeny := func(rule string) {
if _, exists := seenDeny[rule]; exists {
return
}
seenDeny[rule] = struct{}{}
deny = append(deny, rule)
}
for _, skill := range disabled {
key, ok := cleanRuntimeSkillKey(skill.Key)
if !ok {
continue
}
invocationName := strings.TrimSpace(skill.Name)
if invocationName == "" {
invocationName = filepath.Base(filepath.FromSlash(key))
}
if workspaceClaimsRuntimeSkill(invocationName, workspaceSkills) {
continue
}
// Claude Code's skillOverrides fully hides personal/project skills.
// Plugin skills ignore that setting, so the permission deny below is
// also emitted for every key and is the enforcement path for plugins.
if skill.Root != "plugin" {
overrides[invocationName] = "off"
} else {
invocationName = key
}
addDeny("Skill(" + invocationName + ")")
addDeny("Skill(" + invocationName + " *)")
}
if len(overrides) == 0 && len(deny) == 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return "", err
}
return "", nil
}
payload := map[string]any{
"skillOverrides": overrides,
"permissions": map[string]any{
"deny": deny,
},
}
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", err
}
return path, nil
}
func ensureCodexDisabledSkillsConfig(configPath, codexHome string, disabled []RuntimeSkillRefForEnv, workspaceSkills []SkillContextForEnv) error {
if len(disabled) == 0 {
return nil
}
home := ""
paths := make([]string, 0, len(disabled))
seen := make(map[string]struct{}, len(disabled))
for _, skill := range disabled {
key, ok := cleanRuntimeSkillKey(skill.Key)
if !ok {
continue
}
var skillPath string
switch skill.Root {
case "provider":
firstKeyPart := strings.SplitN(key, "/", 2)[0]
if workspaceClaimsRuntimeSkill(firstKeyPart, workspaceSkills) {
continue
}
skillPath = filepath.Join(codexHome, "skills", filepath.FromSlash(key), "SKILL.md")
case "universal":
if home == "" {
var err error
home, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve user home for disabled Codex skills: %w", err)
}
}
skillPath = filepath.Join(home, ".agents", "skills", filepath.FromSlash(key), "SKILL.md")
default:
continue
}
if _, exists := seen[skillPath]; exists {
continue
}
seen[skillPath] = struct{}{}
paths = append(paths, skillPath)
}
if len(paths) == 0 {
return nil
}
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
for _, path := range paths {
block := "\n[[skills.config]]\npath = " + strconv.Quote(filepath.ToSlash(path)) + "\nenabled = false\n"
if _, err := file.WriteString(block); err != nil {
return err
}
}
return nil
}
func workspaceClaimsRuntimeSkill(name string, workspaceSkills []SkillContextForEnv) bool {
claim := sanitizeSkillName(name)
for _, skill := range workspaceSkills {
if sanitizeSkillName(skill.Name) == claim {
return true
}
}
return false
}