diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c05574..3364fff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,8 +245,18 @@ jobs: bin.install Dir["sshx-*"].first => "sshx" end + def caveats + <<~EOS + Install or update the matching Agent skill after installation: + sshx skill install + EOS + end + test do assert_match version.to_s, shell_output("#{bin}/sshx --version") + output = shell_output("#{bin}/sshx skill install --dir=#{testpath}/skills/sshx --json --no-audit") + assert_match '"status":"installed"', output + assert_predicate testpath/"skills/sshx/SKILL.md", :exist? end end EOF diff --git a/AGENT.md b/AGENT.md index 5e269fb..45a1368 100644 --- a/AGENT.md +++ b/AGENT.md @@ -104,16 +104,19 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman usage.go → PrintUsage() help text (keep in sync with flags) dryrun.go → --dry-run local execution plan preview audit.go → local structured JSONL audit events + redaction + skill.go → install the canonical Agent skill embedded in sshx plugin.go → local plugin create/list/show/validate/test/trust/remove inspect.go → one-shot capability execution + observation caching internal/plugin/ → manifests, schemas, scaffolds, trust, built-ins internal/runtimepath/ → ~/.sshx / SSHX_HOME runtime-root resolution +internal/skillinstall/ → conflict-safe, atomic Agent skill installation internal/sshclient/ → SSH/SFTP core client.go → SSHClient: dial, auth, exec, SFTP, sudo-over-stdin remote_state.go → restrictive atomic remote observation I/O validate.go → command safety checks + CommandUsesSudo pkg/errutil/ → error helpers (e.g. ignore benign close/EOF errors) pkg/logger/ → leveled logger (SSHX_LOG_LEVEL) +skills/ → canonical Agent skill plus its embedded asset package ``` ### Execution modes @@ -126,6 +129,7 @@ pkg/logger/ → leveled logger (SSHX_LOG_LEVEL) | `sftp` | `--upload/--download/--list/--mkdir/--rm` | file transfer & remote FS ops | | `password` | `--password-*` | manage keyring secrets | | `host` | `--host-*` | manage `settings.json` host entries | +| `skill` | `sshx skill install` | install/update the embedded Agent skill | | `plugin` | `sshx plugin ` | manage local inspection plugins | | `inspect` | `sshx inspect ... ` | collect/reuse one host observation | @@ -145,6 +149,11 @@ pkg/logger/ → leveled logger (SSHX_LOG_LEVEL) - **Local plugins and trust:** editable assets live under `$SSHX_HOME/plugins/`; trusted digests live in `$SSHX_HOME/plugin-lock.json`. Plugin code never belongs in an Agent skill. +- **Agent skill:** the canonical `skills/sshx/SKILL.md` is embedded in the + binary. `sshx skill install` writes it atomically to + `~/.agents/skills/sshx/SKILL.md` (or the explicit `--dir`); differing content + needs explicit `--force` unless its `.sshx-managed.json` digest proves it was + installed by sshx, and symlink targets are rejected. - **Remote observations:** opt-in cache mode stores only normalized, redacted JSON under the authenticated user's `~/.sshx/observations/v1/`. Collector code remains local and is streamed only for the SSH session. diff --git a/CHANGELOG.md b/CHANGELOG.md index cca81ba..d4b512a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-08-12 + +### Added + +- Add `sshx skill install` with a canonical Skill embedded in the binary, an + idempotent JSON result, configurable destination, atomic writes, conflict + protection, managed-version digest tracking, and symlink rejection. This + makes Skill installation and later upgrades available after Homebrew and + `go install` without another download. + ## [0.1.0] - 2026-08-12 ### Added @@ -285,7 +295,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI/CD workflow and automated release process - Tag creation script -[Unreleased]: https://github.com/talkincode/sshx/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/talkincode/sshx/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/talkincode/sshx/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/talkincode/sshx/compare/v0.0.14...v0.1.0 [0.0.14]: https://github.com/talkincode/sshx/compare/v0.0.13...v0.0.14 [0.0.13]: https://github.com/talkincode/sshx/compare/v0.0.12...v0.0.13 diff --git a/Makefile b/Makefile index 668bd2f..1fb4208 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,6 @@ RELEASE_LDFLAGS=-ldflags "-s -w -X main.Version=$(VERSION)" # Install locations LOCAL_BIN_DIR=$(HOME)/.local/bin SKILL_NAME=sshx -SKILLS_SRC_DIR=skills/$(SKILL_NAME) SKILLS_INSTALL_DIR=$(HOME)/.agents/skills # Go parameters @@ -110,8 +109,7 @@ install: build ## Install binary to ~/.local/bin and skill to ~/.agents/skills @mkdir -p $(LOCAL_BIN_DIR) @cp $(GOBIN)/$(BINARY_NAME) $(LOCAL_BIN_DIR)/$(BINARY_NAME) && chmod +x $(LOCAL_BIN_DIR)/$(BINARY_NAME) @echo "✓ Installed binary to $(LOCAL_BIN_DIR)/$(BINARY_NAME)" - @mkdir -p $(SKILLS_INSTALL_DIR)/$(SKILL_NAME) - @cp -R $(SKILLS_SRC_DIR)/. $(SKILLS_INSTALL_DIR)/$(SKILL_NAME)/ + @"$(LOCAL_BIN_DIR)/$(BINARY_NAME)" skill install --dir="$(SKILLS_INSTALL_DIR)/$(SKILL_NAME)" --force --no-audit @echo "✓ Installed skill to $(SKILLS_INSTALL_DIR)/$(SKILL_NAME)" @case ":$$PATH:" in \ *":$(LOCAL_BIN_DIR):"*) ;; \ diff --git a/README.md b/README.md index eab3bb0..1f884c1 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ go install github.com/talkincode/sshx/cmd/sshx@latest # Then use it anywhere sshx --help sshx -h=192.168.1.100 "uptime" + +# Install the matching Agent skill from the binary +sshx skill install ``` **Note:** Make sure `$GOPATH/bin` (typically `~/go/bin`) is in your PATH. @@ -105,9 +108,12 @@ sshx -h=192.168.1.100 "uptime" ```bash brew install talkincode/tap/sshx +sshx skill install ``` This pulls prebuilt binaries from the [talkincode/homebrew-tap](https://github.com/talkincode/homebrew-tap) repository, updated automatically on every tagged release. +The binary embeds the matching Agent skill; the second command installs it to +`~/.agents/skills/sshx/SKILL.md` without another download. ### One-Line Installation Script @@ -117,8 +123,9 @@ This pulls prebuilt binaries from the [talkincode/homebrew-tap](https://github.c curl -fsSL https://raw.githubusercontent.com/talkincode/sshx/main/install.sh | bash ``` -The installer verifies the release checksum and installs both the binary and -the matching Agent skill at `~/.agents/skills/sshx/SKILL.md`. +The installer verifies the release checksum, installs the binary, and invokes +`sshx skill install --force` to install the matching embedded Agent skill at +`~/.agents/skills/sshx/SKILL.md`. Or download and run: diff --git a/README_CN.md b/README_CN.md index 072584b..016479c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -97,6 +97,9 @@ go install github.com/talkincode/sshx/cmd/sshx@latest # 然后可以在任何地方使用 sshx --help sshx -h=192.168.1.100 "uptime" + +# 从二进制安装匹配版本的 Agent skill +sshx skill install ``` **注意:** 确保 `$GOPATH/bin`(通常是 `~/go/bin`)在您的 PATH 中。 @@ -105,9 +108,12 @@ sshx -h=192.168.1.100 "uptime" ```bash brew install talkincode/tap/sshx +sshx skill install ``` 该命令会从 [talkincode/homebrew-tap](https://github.com/talkincode/homebrew-tap) 仓库拉取预编译二进制文件,每次打 tag 发布时自动更新。 +二进制内嵌了匹配版本的 Agent skill;第二条命令无需再次联网,即可将它安装到 +`~/.agents/skills/sshx/SKILL.md`。 ### 一键安装脚本 @@ -117,8 +123,9 @@ brew install talkincode/tap/sshx curl -fsSL https://raw.githubusercontent.com/talkincode/sshx/main/install.sh | bash ``` -安装脚本会校验 Release 校验和,并同时安装二进制和对应版本的 Agent skill -到 `~/.agents/skills/sshx/SKILL.md`。 +安装脚本会校验 Release 校验和、安装二进制,并调用 +`sshx skill install --force` 将内嵌的匹配版本 Agent skill 安装到 +`~/.agents/skills/sshx/SKILL.md`。 或下载后运行: diff --git a/RELEASE.md b/RELEASE.md index dffc056..99947e6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -169,11 +169,14 @@ users can run: ```bash brew install talkincode/tap/sshx +sshx skill install ``` The formula is built from the `checksums.txt` produced by the `build` job, and covers `darwin`/`linux` on both `amd64` and `arm64`. Windows has no Homebrew -equivalent, so it is intentionally excluded from the formula. +equivalent, so it is intentionally excluded from the formula. The Formula +caveat tells users to run `sshx skill install`; the embedded asset makes this +work even though Homebrew installs only the binary. ### One-time setup diff --git a/docs/getting-started.md b/docs/getting-started.md index 97d2814..2509f11 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,8 +9,14 @@ If Go is already installed: ```bash go install github.com/talkincode/sshx/cmd/sshx@latest sshx --version +sshx skill install ``` +`sshx skill install` writes the canonical skill embedded in the binary to +`~/.agents/skills/sshx/SKILL.md`. The same command should be run after a +Homebrew install or upgrade; prior sshx-managed versions update automatically. +Use `--force` only after reviewing a locally modified existing copy. + You can also run a specific version without installing: ```bash diff --git a/docs/roadmap.md b/docs/roadmap.md index 65e4fff..0eae047 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -235,6 +235,7 @@ Agent / 自动化 / 人类运维者 | 危险动作阻断与显式绕过 | 高 | 是 | 否,仅控制执行准入 | ✅ 显式 `--force` | ✅ 默认阻断且零连接 | ✅ 默认阻断/显式绕过 | 不适用:策略门本身不修改状态 | `tests/e2e/cli_e2e_test.go` | | 本地结构化审计 | 高 | 否 | 是,本地 | ✅ | ✅ 不可写目标可观测 | 不适用:本地调用者同权 | ✅ 修复目标后单事件写入 | `tests/e2e/host_audit_e2e_test.go` | | 本地探测插件生命周期 | 高 | 本地调用者权限 | 是,本地 | ✅ create/list/show/validate/test/trust/remove | ✅ 路径逃逸、重复创建、manifest/entrypoint/schema/fixture 分类失败 | ✅ 私有目录/文件权限 | ✅ replace/remove 保留可恢复备份 | `tests/e2e/inspect_plugin_e2e_test.go` | +| Agent Skill 安装 | 高 | 本地调用者权限 | 是,本地 Agent 信任目录 | ✅ 编译后二进制离线安装/幂等复用 | ✅ 内容冲突与 symlink 目标拒绝 | ✅ 默认目录/显式目录 | ✅ 冲突不覆盖,显式 force 后恢复官方版本 | `tests/e2e/skill_e2e_test.go` | | 单主机探测与内置基线 | 高 | 是 | 否,cache off | ✅ 自定义插件与 `system.baseline` | ✅ 未信任、污染/超限输出、超时、非零退出、不支持平台 | ✅ operator/reader/sudo-required | 不适用:不修改远端状态 | `tests/e2e/inspect_plugin_e2e_test.go`、`tests/e2e/keyring_e2e_test.go` | | 远端观察缓存 | 高 | 是 | 是,远端 JSON | ✅ 冷写入/热复用/并发原子替换 | ✅ TTL/boot ID、格式、大小、属主、权限、symlink、只读端 | ✅ 可写/只读 SFTP | ✅ 失败写入保留原有效快照 | `tests/e2e/inspect_plugin_e2e_test.go` | | 有界多主机执行(方向) | 高 | 是 | 可能,多主机 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | ❌ 未实现 | `--host-test-all` 仅覆盖连接测试,不等同批量执行 | diff --git a/docs/zh/getting-started.md b/docs/zh/getting-started.md index 13cfd3e..9d3031b 100644 --- a/docs/zh/getting-started.md +++ b/docs/zh/getting-started.md @@ -9,8 +9,14 @@ ```bash go install github.com/talkincode/sshx/cmd/sshx@latest sshx --version +sshx skill install ``` +`sshx skill install` 会把二进制内嵌的官方 Skill 写入 +`~/.agents/skills/sshx/SKILL.md`。使用 Homebrew 安装或升级后也运行同一条命令; +由 sshx 管理的旧版本会自动升级,只有在审阅过本地修改副本后,才使用 +`--force` 覆盖。 + 也可以不安装,直接运行指定版本: ```bash diff --git a/install.sh b/install.sh index 2e9eb64..da52401 100755 --- a/install.sh +++ b/install.sh @@ -142,6 +142,36 @@ verify_checksum() { print_success "Checksum verified" } +install_agent_skill() { + local installed_binary="$1" + + # New releases carry the canonical skill inside the binary. Keep the + # archive fallback only for older binaries that do not expose the command; + # a supported command's safety failure must stop the installation. + if "$installed_binary" --help 2>/dev/null | grep -q "sshx skill install"; then + "$installed_binary" skill install \ + --dir="$SKILL_INSTALL_DIR" \ + --force \ + --no-audit >/dev/null + print_success "Installed embedded agent skill to ${SKILL_INSTALL_DIR}/SKILL.md" + return + fi + + if [ -f "SKILL.md" ]; then + if [ -L "$SKILL_INSTALL_DIR" ] || [ -L "${SKILL_INSTALL_DIR}/SKILL.md" ]; then + print_error "Refusing to install the agent skill through a symlinked target" + exit 1 + fi + mkdir -p "$SKILL_INSTALL_DIR" + cp "SKILL.md" "${SKILL_INSTALL_DIR}/SKILL.md" + chmod 0644 "${SKILL_INSTALL_DIR}/SKILL.md" + print_success "Installed archive agent skill to ${SKILL_INSTALL_DIR}/SKILL.md" + return + fi + + print_warning "This sshx version does not provide an installable agent skill" +} + # Download and install install_sshx() { local platform @@ -223,14 +253,7 @@ install_sshx() { sudo cp "$binary_file" "${INSTALL_DIR}/${BINARY_NAME}" && sudo chmod +x "${INSTALL_DIR}/${BINARY_NAME}" fi - if [ -f "SKILL.md" ]; then - mkdir -p "$SKILL_INSTALL_DIR" - cp "SKILL.md" "${SKILL_INSTALL_DIR}/SKILL.md" - chmod 0644 "${SKILL_INSTALL_DIR}/SKILL.md" - print_success "Installed agent skill to ${SKILL_INSTALL_DIR}/SKILL.md" - else - print_warning "This release archive does not include the optional agent skill" - fi + install_agent_skill "${INSTALL_DIR}/${BINARY_NAME}" # Cleanup cd - > /dev/null @@ -299,5 +322,7 @@ main() { print_info "Documentation: https://github.com/${REPO}" } -# Run -main +# Run only when executed, so tests and shell tooling can safely source helpers. +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main +fi diff --git a/internal/app/app.go b/internal/app/app.go index 7c64ff6..d430b9f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -98,6 +98,11 @@ func Run(args []string) (err error) { return nil } + // Handle the local Agent skill lifecycle without crossing the network. + if config.Mode == "skill" { + return HandleSkillManagement(config) + } + // Handle local plugin lifecycle mode. if config.Mode == "plugin" { if pluginErr := HandlePluginManagement(config); pluginErr != nil { diff --git a/internal/app/audit.go b/internal/app/audit.go index 6e298f6..b4c1843 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -364,6 +364,8 @@ func auditAction(config *sshclient.Config) string { return "transfer" case "plugin": return config.PluginAction + case "skill": + return config.SkillAction case "inspect": return "inspect" default: @@ -405,6 +407,8 @@ func auditWouldWriteLocalState(config *sshclient.Config) bool { return config.HostAction == "add" || config.HostAction == "update" || config.HostAction == "remove" || config.HostAction == "import" case "plugin": return config.PluginAction == "create" || config.PluginAction == "trust" || config.PluginAction == "remove" + case "skill": + return config.SkillAction == "install" default: return false } diff --git a/internal/app/audit_test.go b/internal/app/audit_test.go index 352b2a4..e834003 100644 --- a/internal/app/audit_test.go +++ b/internal/app/audit_test.go @@ -267,6 +267,11 @@ func TestAuditEffectFlagsByModeAndAction(t *testing.T) { config: sshclient.Config{Mode: "host", HostAction: "add"}, wantWriteLocalState: true, }, + { + name: "skill install writes only local state", + config: sshclient.Config{Mode: "skill", SkillAction: "install"}, + wantWriteLocalState: true, + }, { name: "host test reads secret mutates remote and may trust host", config: sshclient.Config{Mode: "host", HostAction: "test", AcceptUnknownHost: true}, diff --git a/internal/app/config.go b/internal/app/config.go index 5997603..89b8a07 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -103,6 +103,9 @@ func ParseArgs(args []string) *sshclient.Config { case "plugin": parsePluginArgs(config, args[2:]) return config + case "skill": + parseSkillArgs(config, args[2:]) + return config case "inspect": parseInspectArgs(config, args[2:]) return config @@ -281,6 +284,38 @@ func ParseArgs(args []string) *sshclient.Config { return config } +func parseSkillArgs(config *sshclient.Config, args []string) { + config.Mode = "skill" + // SSH_FORCE controls remote command safety and must never authorize + // overwriting a local Agent trust asset. Only an explicit --force below may. + config.Force = false + if len(args) == 0 { + return + } + config.SkillAction = args[0] + for _, arg := range args[1:] { + switch { + case arg == "--json": + config.JSONOutput = true + case arg == "--force", arg == "-f": + config.Force = true + case strings.HasPrefix(arg, "--dir="): + config.SkillDir = strings.SplitN(arg, "=", 2)[1] + if config.SkillDir == "" { + config.ArgumentError = "--dir must not be empty" + } + case strings.HasPrefix(arg, "--audit-output="): + config.AuditOutput = strings.SplitN(arg, "=", 2)[1] + case arg == "--no-audit": + config.AuditEnabled = false + case !strings.HasPrefix(arg, "-"): + config.ArgumentError = fmt.Sprintf("unexpected skill argument %q", arg) + default: + config.ArgumentError = fmt.Sprintf("unknown skill option %q", arg) + } + } +} + func parsePluginArgs(config *sshclient.Config, args []string) { config.Mode = "plugin" if len(args) == 0 { diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 4223d21..33be0fd 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -40,6 +40,26 @@ func TestParseArgs_PluginSubcommands(t *testing.T) { } } +func TestParseArgs_SkillInstall(t *testing.T) { + config := ParseArgs([]string{ + "sshx", "skill", "install", "--dir=/tmp/agent-skills/sshx", "--force", "--json", "--no-audit", + }) + if config.Mode != "skill" || config.SkillAction != "install" { + t.Fatalf("unexpected skill routing: mode=%s action=%s", config.Mode, config.SkillAction) + } + if config.SkillDir != "/tmp/agent-skills/sshx" || !config.Force || !config.JSONOutput || config.AuditEnabled { + t.Fatalf("unexpected skill options: %#v", config) + } +} + +func TestParseArgs_SkillInstallRequiresExplicitForce(t *testing.T) { + t.Setenv("SSH_FORCE", "true") + config := ParseArgs([]string{"sshx", "skill", "install"}) + if config.Force { + t.Fatal("SSH_FORCE must not authorize overwriting an Agent skill") + } +} + func TestParseArgs_InspectSubcommand(t *testing.T) { config := ParseArgs([]string{ "sshx", "inspect", "-h=prod", "-p=2222", "-u=operator", "system.baseline", @@ -65,6 +85,10 @@ func TestParseArgs_SubcommandsRejectUnknownOptions(t *testing.T) { if inspectConfig.ArgumentError == "" { t.Fatal("unknown inspect option was ignored") } + skillConfig := ParseArgs([]string{"sshx", "skill", "install", "--typo"}) + if skillConfig.ArgumentError == "" { + t.Fatal("unknown skill option was ignored") + } } func TestClassifyPluginErrorDistinguishesValidationBoundaries(t *testing.T) { diff --git a/internal/app/skill.go b/internal/app/skill.go new file mode 100644 index 0000000..beb83fc --- /dev/null +++ b/internal/app/skill.go @@ -0,0 +1,97 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/talkincode/sshx/internal/skillinstall" + "github.com/talkincode/sshx/internal/sshclient" +) + +type skillActionResult struct { + Success bool `json:"success"` + Action string `json:"action"` + Status string `json:"status,omitempty"` + Path string `json:"path,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Source string `json:"source,omitempty"` + Version string `json:"version,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` + Error string `json:"error,omitempty"` +} + +// HandleSkillManagement installs the canonical Agent skill embedded in sshx. +func HandleSkillManagement(config *sshclient.Config) error { + if config.ArgumentError != "" { + return reportSkillError(config, "config", fmt.Errorf("%s", config.ArgumentError)) + } + if config.SkillAction == "" { + return reportSkillError(config, "config", fmt.Errorf("skill action is required: install")) + } + if config.SkillAction != "install" { + return reportSkillError(config, "config", fmt.Errorf("unknown skill action %q", config.SkillAction)) + } + + result, err := skillinstall.Install(skillinstall.Options{ + Dir: config.SkillDir, + Force: config.Force, + }) + if err != nil { + return reportSkillError(config, classifySkillError(err), err) + } + + payload := skillActionResult{ + Success: true, + Action: "install", + Status: result.Status, + Path: result.Path, + SHA256: result.SHA256, + Source: result.Source, + Version: Version, + } + return emitSkillResult(config, payload) +} + +func classifySkillError(err error) string { + switch { + case errors.Is(err, skillinstall.ErrConflict): + return "conflict" + case errors.Is(err, skillinstall.ErrUnsafeTarget): + return "unsafe_target" + default: + return "install_error" + } +} + +func reportSkillError(config *sshclient.Config, kind string, err error) error { + if !config.JSONOutput { + return fmt.Errorf("skill %s failed: %w", config.SkillAction, err) + } + payload := skillActionResult{ + Success: false, + Action: config.SkillAction, + Version: Version, + ErrorKind: kind, + Error: redactError(err), + } + if emitErr := emitSkillResult(config, payload); emitErr != nil { + return emitErr + } + return ErrReported +} + +func emitSkillResult(config *sshclient.Config, result skillActionResult) error { + if config.JSONOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetEscapeHTML(false) + return encoder.Encode(result) + } + if result.Status == "current" { + fmt.Printf("Agent skill is current: %s\n", result.Path) + return nil + } + fmt.Printf("Agent skill %s: %s\n", result.Status, result.Path) + return nil +} diff --git a/internal/app/usage.go b/internal/app/usage.go index ee50605..6fa43cf 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -25,6 +25,7 @@ Usage: sshx --host-test= # Test host connection sshx --host-test-all # Test all host connections sshx --host-remove= # Remove host configuration + sshx skill install [options] # Install/update the bundled Agent skill sshx plugin create [options] # Scaffold a local inspection plugin sshx plugin list [--json] # List built-in and local capabilities sshx inspect -h= [options] # Run one structured host inspection @@ -197,6 +198,20 @@ Plugin Management: explicitly trusted. Editing a trusted manifest, schema, or collector changes the digest and blocks remote execution until it is trusted again. +Agent Skill Installation: + sshx skill install [--dir=] [--force] [--json] + + The canonical sshx Agent skill is embedded in the binary, so installation + does not need a network download or a release archive next to the executable. + The default target is ~/.agents/skills/sshx/SKILL.md. Pass --dir to select + another sshx skill directory. + + A matching installed skill is left unchanged (or repaired to mode 0644). + A prior sshx-managed version is updated using its digest sidecar. Differing + unmanaged content is preserved unless --force is explicit. Symlinked targets + are rejected. JSON status is installed, current, repaired, or updated; + failures use conflict, unsafe_target, or install_error. + Environment Variables (.env): SSH_PASSWORD SSH password (not recommended, use SSH keys or keyring) SSH_KEY_PATH SSH private key path @@ -255,6 +270,13 @@ Inspection Examples: # Inspect once and persist only the redacted observation on the target sshx inspect -h=prod-web docker.environment --cache=remote-prefer --json +Agent Skill Example: + # Install after Homebrew/go install, or refresh after upgrading sshx + sshx skill install + + # Replace a locally modified copy after reviewing the difference + sshx skill install --force --json + SFTP Examples: # Upload file sshx -h=192.168.1.100 --upload=local.txt --to=/tmp/remote.txt diff --git a/internal/app/usage_test.go b/internal/app/usage_test.go index e1e8b00..d33642b 100644 --- a/internal/app/usage_test.go +++ b/internal/app/usage_test.go @@ -46,6 +46,7 @@ func TestPrintUsage(t *testing.T) { "Password Management", "Inspection Capabilities:", "Plugin Management:", + "Agent Skill Installation:", "Environment Variables", "SSH Examples:", "SFTP Examples:", @@ -71,6 +72,7 @@ func TestPrintUsage(t *testing.T) { "--no-safety-check", "sshx inspect", "sshx plugin create", + "sshx skill install", "SSHX_HOME", } @@ -176,6 +178,7 @@ func TestPrintUsage_Examples(t *testing.T) { `--audit-output=./.sshx-audit`, `sshx inspect -h=prod-web system.baseline --json`, `sshx plugin create docker.environment --template=docker`, + `sshx skill install`, } for _, example := range examples { diff --git a/internal/skillinstall/install.go b/internal/skillinstall/install.go new file mode 100644 index 0000000..eb4d3ad --- /dev/null +++ b/internal/skillinstall/install.go @@ -0,0 +1,306 @@ +// Package skillinstall installs the Agent skill embedded in the sshx binary. +package skillinstall + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/talkincode/sshx/skills" +) + +const ( + defaultSkillsDir = ".agents/skills/sshx" + skillFileName = "SKILL.md" + metadataFileName = ".sshx-managed.json" + metadataSchema = "sshx.skill-install.v1" + maxSkillSize = 1 << 20 + maxMetadataSize = 4096 +) + +var ( + // ErrConflict means an existing skill differs from the bundled version. + ErrConflict = errors.New("installed skill differs from bundled skill") + // ErrUnsafeTarget means the destination would cross a symlink or overwrite + // a non-regular filesystem object. + ErrUnsafeTarget = errors.New("unsafe skill installation target") +) + +// Options controls one local skill installation. +type Options struct { + Dir string + Force bool +} + +// Result describes the installed artifact and whether it changed. +type Result struct { + Status string `json:"status"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + Source string `json:"source"` +} + +type managedMetadata struct { + SchemaVersion string `json:"schema_version"` + SHA256 string `json:"sha256"` +} + +// Install writes the canonical embedded Agent skill to the configured target. +// Existing differing content is preserved unless Force is explicit. +func Install(options Options) (Result, error) { + return installContent(options, skills.SSHX()) +} + +func installContent(options Options, content []byte) (Result, error) { + if err := validate(content); err != nil { + return Result{}, fmt.Errorf("invalid bundled skill: %w", err) + } + + dir, err := ResolveDir(options.Dir) + if err != nil { + return Result{}, err + } + if targetErr := ensureTargetDir(dir); targetErr != nil { + return Result{}, targetErr + } + + destination := filepath.Join(dir, skillFileName) + metadataPath := filepath.Join(dir, metadataFileName) + status, metadataCurrent, err := installationStatus(destination, metadataPath, content, options.Force) + if err != nil { + return Result{}, err + } + result := Result{ + Status: status, + Path: destination, + SHA256: digest(content), + Source: "embedded", + } + if status == "current" { + if metadataCurrent { + return result, nil + } + } else if writeErr := writeAtomic(destination, content); writeErr != nil { + return Result{}, fmt.Errorf("install skill at %s: %w", destination, writeErr) + } + metadata := managedMetadata{ + SchemaVersion: metadataSchema, + SHA256: result.SHA256, + } + encodedMetadata, err := json.Marshal(metadata) + if err != nil { + return Result{}, fmt.Errorf("encode skill metadata: %w", err) + } + encodedMetadata = append(encodedMetadata, '\n') + if err := writeAtomic(metadataPath, encodedMetadata); err != nil { + return Result{}, fmt.Errorf("record managed skill metadata at %s: %w", metadataPath, err) + } + return result, nil +} + +// ResolveDir resolves the destination directory. An explicit value wins; +// otherwise the target is ~/.agents/skills/sshx. +func ResolveDir(explicit string) (string, error) { + dir := explicit + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + dir = filepath.Join(home, filepath.FromSlash(defaultSkillsDir)) + } else { + expanded, err := expandHome(dir) + if err != nil { + return "", err + } + dir = expanded + } + + absolute, err := filepath.Abs(filepath.Clean(dir)) + if err != nil { + return "", fmt.Errorf("resolve skill directory %q: %w", dir, err) + } + if absolute == filepath.VolumeName(absolute)+string(filepath.Separator) { + return "", fmt.Errorf("%w: filesystem root is not a valid skill directory", ErrUnsafeTarget) + } + return absolute, nil +} + +func expandHome(path string) (string, error) { + if path != "~" && !strings.HasPrefix(path, "~/") && !strings.HasPrefix(path, `~\`) { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + if path == "~" { + return home, nil + } + return filepath.Join(home, path[2:]), nil +} + +func ensureTargetDir(dir string) error { + info, err := os.Lstat(dir) + switch { + case err == nil: + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: %s must be a real directory", ErrUnsafeTarget, dir) + } + return nil + case !errors.Is(err, os.ErrNotExist): + return fmt.Errorf("inspect skill directory %s: %w", dir, err) + } + + if mkdirErr := os.MkdirAll(dir, 0o755); mkdirErr != nil { // #nosec G301 -- Agent skills are public documentation, not secrets. + return fmt.Errorf("create skill directory %s: %w", dir, mkdirErr) + } + info, err = os.Lstat(dir) + if err != nil { + return fmt.Errorf("verify skill directory %s: %w", dir, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: %s must be a real directory", ErrUnsafeTarget, dir) + } + return nil +} + +func installationStatus(destination, metadataPath string, content []byte, force bool) (string, bool, error) { + metadata, metadataExists, err := readMetadata(metadataPath) + if err != nil { + return "", false, err + } + info, err := os.Lstat(destination) + switch { + case errors.Is(err, os.ErrNotExist): + return "installed", false, nil + case err != nil: + return "", false, fmt.Errorf("inspect existing skill %s: %w", destination, err) + case info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular(): + return "", false, fmt.Errorf("%w: %s must be a regular file", ErrUnsafeTarget, destination) + } + if info.Size() > maxSkillSize { + if !force { + return "", false, fmt.Errorf("%w at %s; existing file exceeds %d bytes and requires --force", ErrConflict, destination, maxSkillSize) + } + return "updated", false, nil + } + + existing, err := os.ReadFile(destination) // #nosec G304 -- destination is the resolved, managed SKILL.md target. + if err != nil { + return "", false, fmt.Errorf("read existing skill %s: %w", destination, err) + } + existingDigest := digest(existing) + desiredDigest := digest(content) + metadataCurrent := metadataExists && metadata.SchemaVersion == metadataSchema && metadata.SHA256 == desiredDigest + if bytes.Equal(existing, content) { + if info.Mode().Perm() != 0o644 { + return "repaired", metadataCurrent, nil + } + return "current", metadataCurrent, nil + } + if !force { + managedPreviousVersion := metadataExists && metadata.SchemaVersion == metadataSchema && metadata.SHA256 == existingDigest + if managedPreviousVersion { + return "updated", false, nil + } + return "", false, fmt.Errorf("%w at %s; review it and rerun with --force to replace it", ErrConflict, destination) + } + return "updated", false, nil +} + +func readMetadata(path string) (managedMetadata, bool, error) { + info, err := os.Lstat(path) + switch { + case errors.Is(err, os.ErrNotExist): + return managedMetadata{}, false, nil + case err != nil: + return managedMetadata{}, false, fmt.Errorf("inspect skill metadata %s: %w", path, err) + case info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular(): + return managedMetadata{}, false, fmt.Errorf("%w: %s must be a regular file", ErrUnsafeTarget, path) + case info.Size() > maxMetadataSize: + return managedMetadata{}, true, nil + } + + data, err := os.ReadFile(path) // #nosec G304 -- path is the managed metadata file beside SKILL.md. + if err != nil { + return managedMetadata{}, false, fmt.Errorf("read skill metadata %s: %w", path, err) + } + var metadata managedMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return managedMetadata{}, true, nil + } + return metadata, true, nil +} + +func writeAtomic(destination string, content []byte) error { + dir := filepath.Dir(destination) + temporaryPrefix := "." + strings.TrimPrefix(filepath.Base(destination), ".") + ".tmp-*" + temporary, err := os.CreateTemp(dir, temporaryPrefix) + if err != nil { + return err + } + temporaryPath := temporary.Name() + removeTemporary := true + defer func() { + if removeTemporary { + _ = os.Remove(temporaryPath) //nolint:errcheck // best-effort cleanup after a failed installation + } + }() + + if err := temporary.Chmod(0o644); err != nil { + _ = temporary.Close() //nolint:errcheck // preserve the original permission error + return err + } + if _, err := temporary.Write(content); err != nil { + _ = temporary.Close() //nolint:errcheck // preserve the original write error + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() //nolint:errcheck // preserve the original sync error + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, destination); err != nil { + return err + } + removeTemporary = false + return nil +} + +func validate(content []byte) error { + if len(content) == 0 { + return fmt.Errorf("content is empty") + } + if len(content) > maxSkillSize { + return fmt.Errorf("content exceeds %d bytes", maxSkillSize) + } + if !bytes.HasPrefix(content, []byte("---\n")) { + return fmt.Errorf("missing YAML frontmatter") + } + end := bytes.Index(content[4:], []byte("\n---\n")) + if end < 0 { + return fmt.Errorf("unterminated YAML frontmatter") + } + frontmatter := string(content[4 : 4+end]) + for _, line := range strings.Split(frontmatter, "\n") { + key, value, found := strings.Cut(line, ":") + if found && strings.TrimSpace(key) == "name" && strings.TrimSpace(value) == "sshx" { + return nil + } + } + return fmt.Errorf("frontmatter name must be sshx") +} + +func digest(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/skillinstall/install_test.go b/internal/skillinstall/install_test.go new file mode 100644 index 0000000..bde3559 --- /dev/null +++ b/internal/skillinstall/install_test.go @@ -0,0 +1,236 @@ +package skillinstall + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestInstallLifecyclePreservesConflictsUntilForced(t *testing.T) { + dir := filepath.Join(t.TempDir(), "skills", "sshx") + + installed, err := Install(Options{Dir: dir}) + if err != nil { + t.Fatalf("install skill: %v", err) + } + if installed.Status != "installed" || installed.Source != "embedded" || installed.SHA256 == "" { + t.Fatalf("unexpected initial result: %#v", installed) + } + content, err := os.ReadFile(installed.Path) + if err != nil { + t.Fatalf("read installed skill: %v", err) + } + if validationErr := validate(content); validationErr != nil { + t.Fatalf("installed invalid skill: %v", validationErr) + } + info, err := os.Stat(installed.Path) + if err != nil { + t.Fatalf("stat installed skill: %v", err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("installed skill mode = %o, want 644", info.Mode().Perm()) + } + metadataInfo, err := os.Stat(filepath.Join(dir, metadataFileName)) + if err != nil { + t.Fatalf("stat managed metadata: %v", err) + } + if metadataInfo.Mode().Perm() != 0o644 { + t.Fatalf("managed metadata mode = %o, want 644", metadataInfo.Mode().Perm()) + } + + current, err := Install(Options{Dir: dir}) + if err != nil { + t.Fatalf("reinstall current skill: %v", err) + } + if current.Status != "current" { + t.Fatalf("reinstall status = %q, want current", current.Status) + } + if chmodErr := os.Chmod(installed.Path, 0o666); chmodErr != nil { // #nosec G302 -- deliberately exercises permission repair. + t.Fatalf("loosen installed skill permissions: %v", chmodErr) + } + repaired, err := Install(Options{Dir: dir}) + if err != nil { + t.Fatalf("repair installed skill permissions: %v", err) + } + if repaired.Status != "repaired" { + t.Fatalf("permission repair status = %q, want repaired", repaired.Status) + } + repairedInfo, err := os.Stat(installed.Path) + if err != nil { + t.Fatalf("stat repaired skill: %v", err) + } + if repairedInfo.Mode().Perm() != 0o644 { + t.Fatalf("repaired skill mode = %o, want 644", repairedInfo.Mode().Perm()) + } + + custom := []byte("custom local skill\n") + if writeErr := os.WriteFile(installed.Path, custom, 0o600); writeErr != nil { + t.Fatalf("write custom skill: %v", writeErr) + } + if _, conflictErr := Install(Options{Dir: dir}); !errors.Is(conflictErr, ErrConflict) { + t.Fatalf("conflicting install error = %v, want ErrConflict", conflictErr) + } + preserved, err := os.ReadFile(installed.Path) + if err != nil { + t.Fatalf("read preserved skill: %v", err) + } + if string(preserved) != string(custom) { + t.Fatalf("conflicting install modified existing content: %q", preserved) + } + + updated, err := Install(Options{Dir: dir, Force: true}) + if err != nil { + t.Fatalf("force update skill: %v", err) + } + if updated.Status != "updated" { + t.Fatalf("force update status = %q, want updated", updated.Status) + } + restored, err := os.ReadFile(installed.Path) + if err != nil { + t.Fatalf("read restored skill: %v", err) + } + if err := validate(restored); err != nil { + t.Fatalf("force update did not restore bundled skill: %v", err) + } +} + +func TestManagedPreviousVersionUpdatesWithoutForce(t *testing.T) { + dir := filepath.Join(t.TempDir(), "skills", "sshx") + oldContent := []byte("---\nname: sshx\ndescription: old\n---\n# Old\n") + newContent := []byte("---\nname: sshx\ndescription: new\n---\n# New\n") + + oldResult, err := installContent(Options{Dir: dir}, oldContent) + if err != nil { + t.Fatalf("install old managed skill: %v", err) + } + if oldResult.Status != "installed" { + t.Fatalf("old install status = %q, want installed", oldResult.Status) + } + + newResult, err := installContent(Options{Dir: dir}, newContent) + if err != nil { + t.Fatalf("update managed skill: %v", err) + } + if newResult.Status != "updated" { + t.Fatalf("managed update status = %q, want updated", newResult.Status) + } + installed, err := os.ReadFile(filepath.Join(dir, skillFileName)) // #nosec G304 -- isolated managed test path. + if err != nil { + t.Fatalf("read updated skill: %v", err) + } + if string(installed) != string(newContent) { + t.Fatalf("managed update content = %q, want %q", installed, newContent) + } + metadata, exists, err := readMetadata(filepath.Join(dir, metadataFileName)) + if err != nil || !exists { + t.Fatalf("read updated metadata: exists=%v err=%v", exists, err) + } + if metadata.SHA256 != digest(newContent) { + t.Fatalf("unexpected updated metadata: %#v", metadata) + } +} + +func TestInstallRejectsSymlinkedTargetDirectoryAndFile(t *testing.T) { + if _, err := os.Lstat("/"); err != nil { + t.Skip("filesystem does not support expected path operations") + } + + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0o750); err != nil { + t.Fatalf("create real directory: %v", err) + } + symlinkDir := filepath.Join(root, "linked") + if err := os.Symlink(realDir, symlinkDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, err := Install(Options{Dir: symlinkDir, Force: true}); !errors.Is(err, ErrUnsafeTarget) { + t.Fatalf("symlink directory error = %v, want ErrUnsafeTarget", err) + } + if _, err := os.Stat(filepath.Join(realDir, skillFileName)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("symlink directory install escaped target: %v", err) + } + + targetDir := filepath.Join(root, "target") + if err := os.Mkdir(targetDir, 0o750); err != nil { + t.Fatalf("create target directory: %v", err) + } + outside := filepath.Join(root, "outside.md") + if err := os.WriteFile(outside, []byte("outside\n"), 0o600); err != nil { + t.Fatalf("write outside file: %v", err) + } + if err := os.Symlink(outside, filepath.Join(targetDir, skillFileName)); err != nil { + t.Fatalf("create target symlink: %v", err) + } + if _, err := Install(Options{Dir: targetDir, Force: true}); !errors.Is(err, ErrUnsafeTarget) { + t.Fatalf("symlink file error = %v, want ErrUnsafeTarget", err) + } + outsideContent, err := os.ReadFile(outside) // #nosec G304 -- outside is an isolated test fixture path. + if err != nil { + t.Fatalf("read outside file: %v", err) + } + if string(outsideContent) != "outside\n" { + t.Fatalf("outside file was modified: %q", outsideContent) + } + + managedDir := filepath.Join(root, "managed") + if _, installErr := Install(Options{Dir: managedDir}); installErr != nil { + t.Fatalf("install managed skill fixture: %v", installErr) + } + metadataPath := filepath.Join(managedDir, metadataFileName) + if removeErr := os.Remove(metadataPath); removeErr != nil { + t.Fatalf("remove managed metadata fixture: %v", removeErr) + } + outsideMetadata := filepath.Join(root, "outside-metadata.json") + if writeErr := os.WriteFile(outsideMetadata, []byte("outside metadata\n"), 0o600); writeErr != nil { + t.Fatalf("write outside metadata: %v", writeErr) + } + if symlinkErr := os.Symlink(outsideMetadata, metadataPath); symlinkErr != nil { + t.Fatalf("create metadata symlink: %v", symlinkErr) + } + if _, installErr := Install(Options{Dir: managedDir, Force: true}); !errors.Is(installErr, ErrUnsafeTarget) { + t.Fatalf("metadata symlink error = %v, want ErrUnsafeTarget", installErr) + } + outsideMetadataContent, err := os.ReadFile(outsideMetadata) // #nosec G304 -- isolated test fixture path. + if err != nil { + t.Fatalf("read outside metadata: %v", err) + } + if string(outsideMetadataContent) != "outside metadata\n" { + t.Fatalf("outside metadata was modified: %q", outsideMetadataContent) + } +} + +func TestResolveDirDefaultAndHomeExpansion(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + defaultDir, err := ResolveDir("") + if err != nil { + t.Fatalf("resolve default directory: %v", err) + } + if defaultDir != filepath.Join(home, ".agents", "skills", "sshx") { + t.Fatalf("default directory = %q", defaultDir) + } + + explicit, err := ResolveDir("~/explicit") + if err != nil { + t.Fatalf("resolve explicit directory: %v", err) + } + if explicit != filepath.Join(home, "explicit") { + t.Fatalf("explicit directory = %q", explicit) + } +} + +func TestValidateRejectsMalformedContent(t *testing.T) { + for _, content := range [][]byte{ + nil, + []byte("name: sshx\n"), + []byte("---\nname: sshx\n"), + []byte("---\nname: other\n---\nbody\n"), + } { + if err := validate(content); err == nil { + t.Fatalf("validate(%q) unexpectedly succeeded", content) + } + } +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index 489b4a9..11aa0f2 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -119,6 +119,10 @@ type Config struct { PluginFixture string PluginReplace bool + // Agent skill lifecycle fields (Mode == "skill"). + SkillAction string + SkillDir string + // Inspection fields (Mode == "inspect"). InspectCapability string InspectCacheMode string diff --git a/scripts/test_install.sh b/scripts/test_install.sh index 7d20c97..7727564 100755 --- a/scripts/test_install.sh +++ b/scripts/test_install.sh @@ -53,6 +53,7 @@ required_functions=( "detect_platform" "get_latest_version" "install_sshx" + "install_agent_skill" "verify_installation" ) diff --git a/skills/embed.go b/skills/embed.go new file mode 100644 index 0000000..5cb69d0 --- /dev/null +++ b/skills/embed.go @@ -0,0 +1,12 @@ +// Package skills exposes the Agent skills shipped with sshx as embedded assets. +package skills + +import _ "embed" + +//go:embed sshx/SKILL.md +var sshxSkill []byte + +// SSHX returns a copy of the canonical sshx Agent skill bundled in the binary. +func SSHX() []byte { + return append([]byte(nil), sshxSkill...) +} diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md index 76a4045..69bab4b 100644 --- a/skills/sshx/SKILL.md +++ b/skills/sshx/SKILL.md @@ -265,6 +265,25 @@ sshx --password-delete=server-A # delete (alias: --password-del) `SSH_NO_SAFETY_CHECK`, `SSH_FORCE`, `SSH_TIMEOUT`, `SSHX_LOG_LEVEL`, `SSHX_HOME` (isolated settings/audit/plugins/trust runtime root). +## Install or refresh this skill + +The canonical skill is embedded in every sshx binary. After Homebrew or +`go install`, install it without another network request: + +```bash +sshx skill install +``` + +The default destination is `~/.agents/skills/sshx/SKILL.md`. A matching file is +left unchanged, while a prior sshx-managed version is updated automatically +using `.sshx-managed.json`. If unmanaged content differs, review it before +explicitly replacing it with `sshx skill install --force`. Use `--dir=` +for another Agent skill directory. + +With `--json`, successful status is `installed`, `current`, `repaired`, or +`updated`. Installation failures use `conflict`, `unsafe_target`, or +`install_error`; `conflict` leaves the existing file untouched. + ## Meta ```bash diff --git a/tests/e2e/harness_test.go b/tests/e2e/harness_test.go index b6ffca5..3e8d447 100644 --- a/tests/e2e/harness_test.go +++ b/tests/e2e/harness_test.go @@ -454,7 +454,7 @@ func isolatedEnvironment(home string, extra map[string]string) []string { "SSH_DISABLE_KEY": {}, "SSH_KNOWN_HOSTS": {}, "SSHX_AUDIT_OUTPUT": {}, "SSHX_NO_AUDIT": {}, "SSH_ACCEPT_UNKNOWN_HOST": {}, "SSH_INSECURE_HOST_KEY": {}, "SSH_NO_SAFETY_CHECK": {}, "SSH_FORCE": {}, "SSH_TIMEOUT": {}, "SSHX_LOG_LEVEL": {}, - "SSHX_HOME": {}, + "SSHX_HOME": {}, "SSHX_SKILLS_DIR": {}, } env := make([]string, 0, len(os.Environ())+len(extra)+3) for _, item := range os.Environ() { diff --git a/tests/e2e/skill_e2e_test.go b/tests/e2e/skill_e2e_test.go new file mode 100644 index 0000000..7530c33 --- /dev/null +++ b/tests/e2e/skill_e2e_test.go @@ -0,0 +1,100 @@ +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type skillInstallResult struct { + Success bool `json:"success"` + Action string `json:"action"` + Status string `json:"status"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + Source string `json:"source"` + ErrorKind string `json:"error_kind"` + Error string `json:"error"` +} + +func TestCLISkillInstallIsStandaloneIdempotentAndConflictSafe(t *testing.T) { + home := t.TempDir() + targetDir := filepath.Join(home, ".agents", "skills", "sshx") + + installed := runSSHX(t, home, []string{"skill", "install", "--json"}, nil) + require.Equal(t, 0, installed.exitCode, installed.stderr) + var installedPayload skillInstallResult + require.NoError(t, json.Unmarshal([]byte(installed.stdout), &installedPayload)) + assert.True(t, installedPayload.Success) + assert.Equal(t, "install", installedPayload.Action) + assert.Equal(t, "installed", installedPayload.Status) + assert.Equal(t, "embedded", installedPayload.Source) + assert.NotEmpty(t, installedPayload.SHA256) + assert.Equal(t, filepath.Join(targetDir, "SKILL.md"), installedPayload.Path) + + want, err := os.ReadFile(filepath.Join(repositoryRoot(), "skills", "sshx", "SKILL.md")) + require.NoError(t, err) + got, err := os.ReadFile(installedPayload.Path) + require.NoError(t, err) + assert.Equal(t, want, got, "compiled binary must install the canonical repository skill") + metadataInfo, err := os.Stat(filepath.Join(targetDir, ".sshx-managed.json")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o644), metadataInfo.Mode().Perm()) + + current := runSSHX(t, home, []string{"skill", "install", "--json"}, nil) + require.Equal(t, 0, current.exitCode, current.stderr) + var currentPayload skillInstallResult + require.NoError(t, json.Unmarshal([]byte(current.stdout), ¤tPayload)) + assert.Equal(t, "current", currentPayload.Status) + + custom := []byte("custom local skill\n") + require.NoError(t, os.WriteFile(installedPayload.Path, custom, 0o600)) + conflict := runSSHX(t, home, []string{"skill", "install", "--json"}, map[string]string{"SSH_FORCE": "true"}) + require.Equal(t, 255, conflict.exitCode, conflict.stderr) + var conflictPayload skillInstallResult + require.NoError(t, json.Unmarshal([]byte(conflict.stdout), &conflictPayload)) + assert.False(t, conflictPayload.Success) + assert.Equal(t, "conflict", conflictPayload.ErrorKind) + preserved, err := os.ReadFile(installedPayload.Path) + require.NoError(t, err) + assert.Equal(t, custom, preserved, "a conflicting skill must remain untouched without --force") + + updated := runSSHX(t, home, []string{ + "skill", "install", "--dir=" + targetDir, "--force", "--json", + }, nil) + require.Equal(t, 0, updated.exitCode, updated.stderr) + var updatedPayload skillInstallResult + require.NoError(t, json.Unmarshal([]byte(updated.stdout), &updatedPayload)) + assert.Equal(t, "updated", updatedPayload.Status) + restored, err := os.ReadFile(installedPayload.Path) + require.NoError(t, err) + assert.Equal(t, want, restored) +} + +func TestCLISkillInstallRejectsSymlinkedDirectoryWithoutEscaping(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows skill installation is outside the current release scope") + } + + home := t.TempDir() + root := t.TempDir() + outside := filepath.Join(root, "outside") + require.NoError(t, os.Mkdir(outside, 0o750)) + linked := filepath.Join(root, "linked") + require.NoError(t, os.Symlink(outside, linked)) + + result := runSSHX(t, home, []string{ + "skill", "install", "--dir=" + linked, "--force", "--json", + }, nil) + require.Equal(t, 255, result.exitCode, result.stderr) + var payload skillInstallResult + require.NoError(t, json.Unmarshal([]byte(result.stdout), &payload)) + assert.Equal(t, "unsafe_target", payload.ErrorKind) + _, err := os.Stat(filepath.Join(outside, "SKILL.md")) + assert.ErrorIs(t, err, os.ErrNotExist) +}