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
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ Implemented functions:

Install options use the same concepts across languages:

- `appId` / `AppID` / `app_id`: owner id written to `.kitup.json`
- `appId` / `AppID` / `app_id`: non-empty owner id written to `.kitup.json`
- `skillBundle` / `SkillBundle` / `skill_bundle`: local directory, embedded files, or public GitHub bundle
- `scope`: `user` or `project`
- `agents`: `"auto"`, `"*"`, or explicit host ids
Expand Down
1 change: 1 addition & 0 deletions docs/host-adapter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Each host entry describes where a local Agent Skill can be installed and how the
The first path is the canonical install target for that host. Later paths are compatible discovery roots that the host also scans. SDKs should install to the first path unless a caller explicitly requests another supported path.

Project paths must be relative paths. User paths must be home-relative paths beginning with `~/`.
All adapter paths use `/` separators and non-empty segments; `..`, backslashes, colons, and NUL bytes are invalid.

If multiple selected hosts resolve to the same target directory, SDKs must copy once and associate that installed target with every matching host. Shared roots such as `.agents/skills` are common and should not produce duplicate writes.

Expand Down
119 changes: 109 additions & 10 deletions go/kitup.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ type normalizedSkillBundle struct {

var skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)

func isValidSkillName(skillName string) bool {
return skillNamePattern.MatchString(skillName)
}

func LoadHostSpec(hostsFile string) ([]Host, error) {
data := []byte(defaultHostsSpecJSON)
if hostsFile != "" {
Expand All @@ -414,9 +418,53 @@ func LoadHostSpec(hostsFile string) ([]Host, error) {
if err := json.Unmarshal(data, &spec); err != nil {
return nil, err
}
if err := validateHostSpec(spec.Hosts); err != nil {
return nil, err
}
return spec.Hosts, nil
}

func validateHostSpec(hosts []Host) error {
for _, host := range hosts {
for _, path := range host.ProjectSkillsDir {
if !isProjectHostPath(path) {
return fmt.Errorf("invalid project path %q for host %q", path, host.ID)
}
}
for _, path := range host.UserSkillsDir {
if !isHomeHostPath(path) {
return fmt.Errorf("invalid user path %q for host %q", path, host.ID)
}
}
for _, path := range host.Detect {
if !isHomeHostPath(path) && !isProjectHostPath(path) {
return fmt.Errorf("invalid detect path %q for host %q", path, host.ID)
}
}
}
return nil
}

func isProjectHostPath(path string) bool {
return path != "" && !strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "~") && isSafeHostPath(path)
}

func isHomeHostPath(path string) bool {
return strings.HasPrefix(path, "~/") && isSafeHostPath(path[2:])
}

func isSafeHostPath(path string) bool {
if strings.ContainsAny(path, "\x00\\:") {
return false
}
for _, segment := range strings.Split(path, "/") {
if segment == ".." || segment == "" {
return false
}
}
return true
}

func ResolveHosts(agents AgentSelector, hosts []Host) ([]Host, []map[string]any) {
if agents.Kind == "*" {
return hosts, []map[string]any{}
Expand Down Expand Up @@ -522,6 +570,12 @@ func ResolveInstallSelection(opts InstallSelectionOptions) (InstallSelection, er
}

func ResolveInstallTargets(opts BaseOptions, agents AgentSelector, scope Scope, skillName string) ([]TargetGroup, []map[string]any, []string, error) {
if !isValidSkillName(skillName) {
return nil, []map[string]any{{
"skillName": skillName,
"reason": "invalid-skill-name",
}}, nil, nil
}
hosts, err := LoadHostSpec(opts.HostsFile)
if err != nil {
return nil, nil, nil, err
Expand Down Expand Up @@ -644,7 +698,7 @@ func validateNormalizedSkill(bundle normalizedSkillBundle) SkillInfo {
fields := parseFrontmatter(text[4 : 4+end])
name := fields["name"]
description := fields["description"]
if !skillNamePattern.MatchString(name) || len(description) < 1 || len(description) > 1024 {
if !isValidSkillName(name) || len(description) < 1 || len(description) > 1024 {
return SkillInfo{Valid: false, ErrorCode: "invalid-frontmatter"}
}
return SkillInfo{Valid: true, SkillName: name, Description: description}
Expand Down Expand Up @@ -783,6 +837,9 @@ func UpdateBundledSkill(opts InstallOptions) (InstallReport, error) {
}

func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) {
if opts.AppID == "" {
return emptyUninstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil
}
targets, errs, _, err := ResolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, opts.SkillName)
if err != nil {
return UninstallReport{}, err
Expand All @@ -794,7 +851,7 @@ func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) {
switch {
case !present:
report.Skipped = append(report.Skipped, withReason(result, "missing"))
case !managed:
case !managed || meta.SkillName != opts.SkillName:
report.Conflicts = append(report.Conflicts, withReason(result, "unmanaged"))
case meta.AppID != opts.AppID:
report.Conflicts = append(report.Conflicts, withReason(result, "owner-mismatch"))
Expand All @@ -809,6 +866,9 @@ func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) {
}

func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) {
if opts.AppID == "" {
return emptyInstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil
}
bundle, bundleMeta, err := resolveSkillBundle(opts.SkillBundle)
if err != nil {
reason := "invalid-skill-bundle"
Expand Down Expand Up @@ -838,7 +898,7 @@ func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) {
}
}
report.Installed = append(report.Installed, result)
case !managed:
case !managed || meta.SkillName != skill.SkillName:
if opts.Force {
if write {
if err := replaceManagedSkill(bundle, target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil {
Expand Down Expand Up @@ -888,21 +948,33 @@ func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) {
}

func copyManagedSkill(bundle normalizedSkillBundle, targetDir, appID, skillName, hash string, bundleMeta bundleMetadata) error {
if err := os.RemoveAll(targetDir); err != nil {
tmp, err := makeStagingDir(targetDir)
if err != nil {
return err
}
if err := copySkillBundle(bundle, targetDir); err != nil {
if err := copySkillBundle(bundle, tmp); err != nil {
_ = os.RemoveAll(tmp)
return err
}
if err := writeMetadata(tmp, appID, skillName, hash, bundleMeta); err != nil {
_ = os.RemoveAll(tmp)
return err
}
if err := os.Rename(tmp, targetDir); err != nil {
_ = os.RemoveAll(tmp)
return err
}
return writeMetadata(targetDir, appID, skillName, hash, bundleMeta)
return nil
}

func replaceManagedSkill(bundle normalizedSkillBundle, targetDir, appID, skillName, hash string, bundleMeta bundleMetadata) error {
suffix := ".kitup-" + time.Now().Format("20060102150405.000000000")
tmp := targetDir + suffix
backup := targetDir + suffix + "-backup"
_ = os.RemoveAll(tmp)
tmp, err := makeStagingDir(targetDir)
if err != nil {
return err
}
backup := tmp + "-backup"
if err := copySkillBundle(bundle, tmp); err != nil {
_ = os.RemoveAll(tmp)
return err
}
if err := writeMetadata(tmp, appID, skillName, hash, bundleMeta); err != nil {
Expand All @@ -923,6 +995,22 @@ func replaceManagedSkill(bundle normalizedSkillBundle, targetDir, appID, skillNa
return os.RemoveAll(backup)
}

func makeStagingDir(targetDir string) (string, error) {
parent := filepath.Dir(targetDir)
if err := os.MkdirAll(parent, 0o755); err != nil {
return "", err
}
tmp, err := os.MkdirTemp(parent, "."+filepath.Base(targetDir)+".kitup-")
if err != nil {
return "", err
}
if err := os.Chmod(tmp, 0o755); err != nil {
_ = os.RemoveAll(tmp)
return "", err
}
return tmp, nil
}

func copySkillBundle(bundle normalizedSkillBundle, dest string) error {
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
Expand Down Expand Up @@ -999,9 +1087,20 @@ func readMetadata(targetDir string) (metadata, bool, bool) {
if err := json.Unmarshal(data, &meta); err != nil {
return metadata{}, true, false
}
if !isOwnedMetadata(meta) {
return metadata{}, true, false
}
return meta, true, true
}

func isOwnedMetadata(meta metadata) bool {
return meta.SchemaVersion == 1 &&
meta.AppID != "" &&
isValidSkillName(meta.SkillName) &&
(meta.Source == "bundled" || meta.Source == "github") &&
meta.Hash != ""
}

func targetResult(target TargetGroup) TargetResult {
result := TargetResult{SkillName: target.SkillName, TargetDir: target.TargetDir}
if len(target.HostIDs) == 1 {
Expand Down
44 changes: 31 additions & 13 deletions go/kitup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) {
}), tc.Expected["parsed"].(map[string]any))
case "resolve-install-selection":
selection, err := ResolveInstallSelection(InstallSelectionOptions{
BaseOptions: baseOptions(home, workspace),
BaseOptions: caseBaseOptions(tc, home, workspace),
Scope: Scope(opts["scope"].(string)),
Agents: agentSelector(opts["agents"]),
Yes: boolValue(opts["yes"]),
Expand All @@ -109,7 +109,7 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) {
var out bytes.Buffer
report, err := RunBundledSkillInstall(InstallWorkflowOptions{
InstallOptions: InstallOptions{
BaseOptions: baseOptions(home, workspace),
BaseOptions: caseBaseOptions(tc, home, workspace),
AppID: opts["appId"].(string),
SkillBundle: skillBundleFromOptions(opts),
Scope: Scope(stringValue(opts["scope"])),
Expand Down Expand Up @@ -139,12 +139,21 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) {
assertExpectedFileModes(t, tc, home, workspace)
assertExpectedMetadata(t, tc, home, workspace)
default:
base := caseBaseOptions(tc, home, workspace)
if expected, ok := tc.Expected["detectedHosts"]; ok {
hosts, err := DetectHosts(BaseOptions{Home: home, CWD: workspace, HostsFile: repoPathFromCase("spec/hosts.json")}, Scope(opts["scope"].(string)))
hosts, err := DetectHosts(base, Scope(opts["scope"].(string)))
must(t, err)
equal(t, hostIDs(hosts), expected)
}
report := runReportCase(t, tc, opts, home, workspace)
report, err := runReportCase(t, tc, opts, base)
if throws, ok := tc.Expected["throws"].(bool); ok && throws {
if err == nil {
t.Fatal("expected operation to throw")
}
assertExpectedFiles(t, tc, home, workspace)
return
}
must(t, err)
if expected, ok := tc.Expected["report"]; ok {
equal(t, report, expandValue(expected, home, workspace))
}
Expand All @@ -155,19 +164,16 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) {
}
}

func runReportCase(t *testing.T, tc goldenCase, opts map[string]any, home, workspace string) any {
base := baseOptions(home, workspace)
func runReportCase(t *testing.T, tc goldenCase, opts map[string]any, base BaseOptions) (any, error) {
switch tc.Operation {
case "uninstall":
report, err := UninstallBundledSkill(UninstallOptions{
return UninstallBundledSkill(UninstallOptions{
BaseOptions: base,
AppID: opts["appId"].(string),
SkillName: opts["skillName"].(string),
Scope: Scope(opts["scope"].(string)),
Agents: agentSelector(opts["agents"]),
})
must(t, err)
return report
case "install", "update", "plan":
fn := InstallBundledSkill
if tc.Operation == "update" {
Expand All @@ -176,25 +182,37 @@ func runReportCase(t *testing.T, tc goldenCase, opts map[string]any, home, works
if tc.Operation == "plan" {
fn = PlanBundledSkill
}
report, err := fn(InstallOptions{
return fn(InstallOptions{
BaseOptions: base,
AppID: opts["appId"].(string),
SkillBundle: skillBundleFromOptions(opts),
Scope: Scope(opts["scope"].(string)),
Agents: agentSelector(opts["agents"]),
Force: boolValue(opts["force"]),
})
must(t, err)
return report
default:
t.Fatalf("unsupported operation: %s", tc.Operation)
return nil
return nil, nil
}
}

func baseOptions(home, workspace string) BaseOptions {
return BaseOptions{Home: home, CWD: workspace, HostsFile: repoPathFromCase("spec/hosts.json")}
}

func caseBaseOptions(tc goldenCase, home, workspace string) BaseOptions {
base := baseOptions(home, workspace)
if hostsFile, ok := tc.Options["hostsFile"].(string); ok && hostsFile != "" {
expanded := expandString(hostsFile, home, workspace)
if filepath.IsAbs(expanded) {
base.HostsFile = expanded
} else {
base.HostsFile = repoPathFromCase(expanded)
}
}
return base
}

func setupGiven(t *testing.T, tc goldenCase, home, workspace string) {
for _, dir := range stringSlice(tc.Given["dirs"]) {
must(t, os.MkdirAll(expandString(dir, home, workspace), 0o755))
Expand Down
27 changes: 27 additions & 0 deletions python/src/kitup/_metadata.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
from __future__ import annotations

import json
import re
from pathlib import Path

_SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")


def is_valid_skill_name(skill_name: str) -> bool:
return bool(_SKILL_NAME_RE.fullmatch(skill_name))


def write_install_metadata(
target_dir: Path,
Expand Down Expand Up @@ -43,4 +50,24 @@ def read_install_metadata(target_dir: Path) -> dict[str, object] | None:
return None
if not isinstance(payload, dict):
return None
if not is_owned_metadata(payload):
return None
return payload


def is_owned_metadata(payload: dict[str, object]) -> bool:
if payload.get("schemaVersion") != 1:
Comment thread
samzong marked this conversation as resolved.
return False
app_id = payload.get("appId")
skill_name = payload.get("skillName")
source = payload.get("source")
digest = payload.get("hash")
return (
isinstance(app_id, str)
and bool(app_id)
and isinstance(skill_name, str)
and is_valid_skill_name(skill_name)
and source in ("bundled", "github")
and isinstance(digest, str)
and bool(digest)
)
Loading
Loading