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
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <action>` | manage local inspection plugins |
| `inspect` | `sshx inspect ... <capability-id>` | collect/reuse one host observation |

Expand All @@ -145,6 +149,11 @@ pkg/logger/ → leveled logger (SSHX_LOG_LEVEL)
- **Local plugins and trust:** editable assets live under
`$SSHX_HOME/plugins/<id>`; 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.
Expand Down
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):"*) ;; \
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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:

Expand Down
11 changes: 9 additions & 2 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 中。
Expand All @@ -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`。

### 一键安装脚本

Expand All @@ -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`。

或下载后运行:

Expand Down
5 changes: 4 additions & 1 deletion RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 仅覆盖连接测试,不等同批量执行 |
Expand Down
6 changes: 6 additions & 0 deletions docs/zh/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 35 additions & 10 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions internal/app/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions internal/app/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
35 changes: 35 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading