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
16 changes: 11 additions & 5 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ the project's mission:
installed on managed hosts and do not turn sshx into a fleet control plane.
- ❌ **Desired-state configuration / workflow orchestration** — bounded fan-out
execution is in scope; playbooks, schedulers, reconciliation, and long-lived
workflow state are not.
workflow state are not. `sshx apply` may replace one file; it must not
validate-and-reload a service in the same invocation.
- ❌ **GUI / TUI** — interaction is through flags and stdout/stderr only.
- ❌ **Full OpenSSH replacement** — no interactive login shell multiplexing,
port forwarding / tunneling, SOCKS proxy, X11 forwarding, or agent forwarding.
Expand All @@ -87,10 +88,13 @@ actions, bounded multi-host execution, password/secret references, named host
management, authentication UX, safety checks, auditing, and cross-platform
correctness. Read-only host inspection, local plugin lifecycle, explicit plugin
trust, and bounded observation reuse are also in scope. Guarded SQL execution
(`sshx sql`) is a deliberate scope expansion: statements run through the
database client already present on the remote host (psql or sqlite3), sshx
embeds no database driver, opens no tunnel, and keeps the one-shot
connect–execute–exit model.
(`sshx sql`) and guarded file apply (`sshx apply`) are deliberate scope
expansions: they absorb mutation risk (classify → precondition → backup →
atomic change → structured result) without becoming a workflow engine.

**Convergence test:** every new sshx feature must remove an Agent judgment, not
add a command the Agent has to learn. Absorb remote tax (host, credential,
sudo, timeout, error class, backup). Do not wrap local Unix tools as new verbs.

## 4. Architecture

Expand All @@ -113,6 +117,7 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman
plugin.go → local plugin create/list/show/validate/test/trust/remove
inspect.go → one-shot capability execution + observation caching
sql.go → sshx sql: guarded SQL pipeline (classify → gate → explain → backup → execute)
apply.go → sshx apply: guarded single-file mutation (hash → backup → atomic write)
internal/execution/ → versioned request/result model, selectors, executor
internal/plugin/ → manifests, schemas, scaffolds, trust, built-ins
internal/runtimepath/ → ~/.sshx / SSHX_HOME runtime-root resolution
Expand Down Expand Up @@ -142,6 +147,7 @@ skills/ → canonical Agent skill plus its embedded asset packa
| `plugin` | `sshx plugin <action>` | manage local inspection plugins |
| `inspect` | `sshx inspect ... <capability-id>` | collect/reuse one host observation |
| `sql` | `sshx sql ... "<statement>"` | guarded SQL via remote psql or sqlite3 |
| `apply` | `sshx apply --path= --from=` | guarded single-file remote replace |

### State & storage

Expand Down
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.0] - 2026-08-16

### Added

- Add guarded single-file apply through `sshx apply --path=<abs> --from=<local>`.
The pipeline checks an optional `--expect-sha256` precondition, writes an
owner-only backup, atomically replaces a regular file while preserving mode
and owner, and returns `changed`, hashes, and `rollback_available`. `--sudo`
stages the payload over SFTP and installs with a privileged stdin script.
Reload/restart stays outside the command.

### Security

- Refuse apply to symlinks, directories, and critical identity files
(`/etc/passwd`, `/etc/shadow`, `/etc/sudoers`) unless `--force --bypass-reason=`
is explicit. `--no-backup` requires `--force`.

## [0.5.0] - 2026-08-15

### Added
Expand Down Expand Up @@ -389,7 +406,9 @@ 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.4.1...HEAD
[Unreleased]: https://github.com/talkincode/sshx/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/talkincode/sshx/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/talkincode/sshx/compare/v0.4.1...v0.5.0
[0.4.1]: https://github.com/talkincode/sshx/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/talkincode/sshx/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/talkincode/sshx/compare/v0.2.0...v0.3.0
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ It remains a single binary with one-shot invocations and no resident component o
8. Direct server-to-server transfer, streamed through the local machine without touching local disk.
9. One-shot host inspection with built-in system/network capabilities, local
sshx-owned plugins, explicit digest trust, and freshness-bounded observations.
10. Guarded single-file apply: hash precondition, backup, and atomic replace.

## Installation

Expand Down
1 change: 1 addition & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Agent 需要的不是另一个交互式 SSH shell,而是一份稳定、可组
8. 服务器到服务器直接文件传输,数据经本机流式中转而不落地。
9. 单次主机环境探测:内置系统/网络能力,应用级插件归 sshx 本地运行目录管理,
支持摘要信任和有有效期的观察快照。
10. 受控单文件 apply:哈希前置条件、备份和原子替换。

## 安装

Expand Down
2 changes: 2 additions & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- [Getting Started](getting-started.md)
- [Host Management](host-management.md)
- [SFTP Workflows](sftp.md)
- [Guarded File Apply](apply.md)
- [Agent and Script Mode](agent-scripting.md)
- [Inspection Capabilities and Local Plugins](inspection-plugins.md)
- [Usage Scenarios](usage-scenarios.md)
Expand All @@ -13,6 +14,7 @@
- [快速开始](zh/getting-started.md)
- [主机管理](zh/host-management.md)
- [SFTP 工作流](zh/sftp.md)
- [受控文件 Apply](zh/apply.md)
- [Agent 与脚本模式](zh/agent-scripting.md)
- [主机探测能力与本地插件](zh/inspection-plugins.md)
- [使用场景](zh/usage-scenarios.md)
Expand Down
13 changes: 13 additions & 0 deletions docs/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ else
fi
```

## Guarded File Apply

Prefer `sshx apply` when replacing one remote regular file. Branch on
`changed`, `completion`, and `error_kind`. A `precondition` failure means the
file was not written.

```bash
sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \
--from=./nginx.conf --expect-sha256="$current" --sudo --json
```

Reload stays a separate `sshx run`. See [Guarded File Apply](apply.md).

## Reusable Host Inspection

Before repeating a chain of discovery commands, list and run a bounded
Expand Down
47 changes: 47 additions & 0 deletions docs/apply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Guarded File Apply

`sshx apply` replaces one remote regular file. It is the file equivalent of `sshx sql`: classify the target, check a hash precondition, write a backup, then atomically replace the file. Reload and restart stay outside this command.

```bash
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \
--expect-sha256=<current> --sudo --json
```

## What Apply Does

1. Refuse anything that is not a clean absolute regular-file path.
2. Block `/etc/passwd`, `/etc/shadow`, and `/etc/sudoers` unless `--force --bypass-reason=` is explicit.
3. Read the current file (if it exists) and compare `--expect-sha256` when provided.
4. Copy the current file to `~/.sshx/file-backups/` unless `--no-backup --force` is set.
5. Write a same-directory temp file, preserve mode and owner, then rename over the target.
6. Return `changed`, `before_sha256`, `after_sha256`, `backup.path`, and `completion`.

If the remote content already matches the payload, apply succeeds with `changed=false` and does not write a backup.

## Privileged Paths

SFTP runs as the SSH user. Use `--sudo` when the target is not writable by that user. sshx stages the payload under the remote home directory, then runs a privileged stdin script to install it. The script is never left on the host.

```bash
sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \
--from=./nginx.conf --sudo --json
```

Validation and service reload are separate `sshx run` invocations:

```bash
sshx run --target=prod-web --json -- "sudo nginx -t"
sshx run --target=prod-web --json -- "sudo systemctl reload nginx"
```

## Preview

```bash
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf --dry-run --json
```

Dry-run hashes the local file and prints the local plan. It does not connect or mutate the remote file.

## When To Keep Using SFTP

Use `--upload` / `--download` for moving bytes without a backup contract. Use `apply` when an existing remote file may be overwritten and the caller needs a hash, a backup, and a decidable `changed` result.
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The documentation starts in English by default. Use the language switch in the t
- Save sudo passwords in the operating system keyring instead of plaintext files.
- Use short host names from `~/.sshx/settings.json` instead of repeating IP, port, user, and key paths.
- Perform small SFTP tasks without opening an interactive client.
- Replace one remote regular file with a hash, backup, and atomic write.
- Produce JSON output that scripts and AI agents can branch on.
- Preview local execution plans with `--dry-run` before connecting, reading secrets, mutating `known_hosts`, or writing host config.
- Keep a local JSONL audit trail without recording plaintext passwords, private keys, stdout, or stderr.
Expand Down Expand Up @@ -82,3 +83,4 @@ Read [Security Guidelines](security-guidelines.md) before using `sshx` in produc
- [Agent and Script Mode](agent-scripting.md) explains JSON output, exit codes, timeouts, and audit logs.
- [Inspection Capabilities and Local Plugins](inspection-plugins.md) explains built-ins, `plugin create`, trust, and observations.
- [SFTP Workflows](sftp.md) covers upload, download, list, mkdir, and remove.
- [Guarded File Apply](apply.md) replaces one remote file with backup and hash checks.
5 changes: 5 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ Agent / 自动化 / 人类运维者

`sshx sql` 通过远端已有的 `psql` 或 `sqlite3` 执行恰好一条语句:本地 fail-closed 分类、策略门闩、变更前备份、结构化 JSON 与审计。PostgreSQL 另有 EXPLAIN 行数估计、表锁事务备份和容器凭据发现;SQLite 以绝对文件路径为身份,只读走 `file:?mode=ro`,变更在 `BEGIN IMMEDIATE` 下做表 CSV 或整文件 `.backup`。证据:`internal/app/sql.go`、`internal/sqlsafe/`、`tests/e2e/sql_sqlite_e2e_test.go`。

- **受控文件 Apply**

`sshx apply` 替换一个远程正则文件:绝对路径门闩、可选 `--expect-sha256` 前置条件、默认 owner-only 备份、同目录临时文件 + rename、保留权限/所有者。`--sudo` 先经 SFTP 暂存再特权安装。不包含 nginx -t 或 reload。证据:`internal/app/apply.go`、`internal/sshclient/apply.go`、`tests/e2e/apply_e2e_test.go`。

- **本地结构化审计**

非 dry-run 调用默认写入本地 JSONL 审计事件,记录目标、动作、安全上下文、结果和耗时,排除 stdout/stderr,并对命令中的 secret-like 参数做尽力脱敏。证据:`internal/app/audit.go`、`internal/app/audit_test.go`。
Expand Down Expand Up @@ -245,5 +249,6 @@ Agent / 自动化 / 人类运维者
| 有界多主机执行 | 高 | 是 | 可能,多主机 | ✅ `sshx run` 组/标签选择 + concurrency 1/4/8/32 | ✅ fail_fast、部分失败、零匹配 | ✅ operator 密码角色 | ✅ 每个选中目标都有终态事件 | `tests/e2e/run_e2e_test.go`、`internal/execution/*_test.go` |
| 可解释执行治理 | 高 | 是 | 可能 | ✅ run 契约 dry-run/digest/intent/bypass_reason | ✅ blocked、uncertain completion、typed error.kind | ✅ SSH login vs sudo key 分离 | ✅ completion 指导 verify_first/unsafe | `tests/e2e/run_e2e_test.go`、`internal/app/run.go`、`internal/execution` |
| 受控 SQL 执行(PostgreSQL / SQLite) | 高 | 是 | 是,远端库 | ✅ sqlite 只读查询与带备份 UPDATE | ✅ 直连客户端阻断、ATTACH 分类拒绝、缺路径 | ✅ operator 密码角色 | ✅ UPDATE 前 CSV 可还原旧值 | `tests/e2e/sql_sqlite_e2e_test.go`、`internal/sqlsafe/*_test.go`、`internal/app/sql_test.go` |
| 受控文件 Apply | 高 | 是 | 是,远端文件 | ✅ 创建/覆盖/幂等 | ✅ 哈希不匹配、符号链接、只读端 | ✅ operator/reader | ✅ 覆盖前备份可还原旧值 | `tests/e2e/apply_e2e_test.go`、`internal/app/apply_test.go`、`internal/sshclient/apply_test.go` |

当前已达到已实现一级能力的覆盖底线。表中的剩余红项属于尚未实现的方向能力,而不是用组件测试掩盖的既有质量债。未来任何一级能力不得只以参数解析或组件测试作为完成依据;必须沿用编译后二进制边界补充 E2E,并同步更新本矩阵。
11 changes: 3 additions & 8 deletions docs/sftp.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,11 @@
sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf
```

Safe production pattern:
To overwrite an existing remote file with a backup and hash precondition, use [Guarded File Apply](apply.md) instead of assembling upload + `install` yourself:

```bash
# Upload to a temporary path first
sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf

# Inspect the uploaded file before moving it into place
sshx -h=prod-web "sudo install -m 0644 /tmp/nginx.conf /etc/nginx/nginx.conf"
sshx -h=prod-web "sudo nginx -t"
sshx -h=prod-web "sudo systemctl reload nginx"
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./deploy/nginx.conf --sudo --json
sshx run --target=prod-web --json -- "sudo nginx -t"
```

## Download A File
Expand Down
12 changes: 12 additions & 0 deletions docs/zh/agent-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ else
fi
```

## 受控文件 Apply

覆盖一个远程正则文件时优先用 `sshx apply`。根据 `changed`、`completion` 和
`error_kind` 分支。`precondition` 表示文件没有被写入。

```bash
sshx apply --target=prod-web --path=/etc/nginx/nginx.conf \
--from=./nginx.conf --expect-sha256="$current" --sudo --json
```

reload 仍是另一次 `sshx run`。详见 [受控文件 Apply](apply.md)。

## 可复用主机探测

在重复执行一串环境发现命令前,先列出并调用有界探测能力:
Expand Down
42 changes: 42 additions & 0 deletions docs/zh/apply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 受控文件 Apply

`sshx apply` 替换一个远程正则文件。它是文件版的 `sshx sql`:判断目标、检查哈希前置条件、写备份,然后原子替换。服务校验和 reload 不属于这条命令。

```bash
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf \
--expect-sha256=<current> --sudo --json
```

## Apply 做什么

1. 拒绝非干净绝对路径、目录、符号链接和设备节点。
2. 默认阻断 `/etc/passwd`、`/etc/shadow`、`/etc/sudoers`,除非显式 `--force --bypass-reason=`。
3. 读取现有文件,并在提供 `--expect-sha256` 时做前置校验。
4. 除非 `--no-backup --force`,否则把原文复制到 `~/.sshx/file-backups/`。
5. 在同目录写临时文件,保留权限和所有者,再 rename 覆盖目标。
6. 返回 `changed`、`before_sha256`、`after_sha256`、`backup.path` 和 `completion`。

如果远程内容已经与 payload 一致,apply 以 `changed=false` 成功,且不写备份。

## 特权路径

SFTP 以 SSH 用户身份运行。目标对该用户不可写时使用 `--sudo`。sshx 先把 payload 暂存到远端 home,再通过 stdin 执行特权安装脚本;脚本不会留在主机上。

校验和 reload 用另一次 `sshx run`:

```bash
sshx run --target=prod-web --json -- "sudo nginx -t"
sshx run --target=prod-web --json -- "sudo systemctl reload nginx"
```

## 预览

```bash
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./nginx.conf --dry-run --json
```

dry-run 只哈希本地文件并打印本地计划,不连接、不改远程文件。

## 什么时候继续用 SFTP

只搬字节、不需要备份合同时用 `--upload` / `--download`。会覆盖已有远程文件、需要哈希、备份和可判定的 `changed` 时用 `apply`。
2 changes: 2 additions & 0 deletions docs/zh/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- 把 sudo 密码保存到操作系统密钥链,而不是明文文件。
- 用 `~/.sshx/settings.json` 里的主机短名称代替重复输入 IP、端口、用户和 key 路径。
- 不打开交互式 SFTP 客户端,也能完成常见文件上传、下载和目录操作。
- 用哈希前置条件、备份和原子替换安全地改一个远程文件。
- 输出适合脚本和 AI agent 判断分支的 JSON。
- 用 `--dry-run` 在连接、读取 secret、修改 `known_hosts` 或写配置前预览本地执行计划。
- 写入本地 JSONL 审计日志,同时不记录明文密码、私钥、stdout 或 stderr。
Expand Down Expand Up @@ -82,3 +83,4 @@ sshx inspect -h=prod-web system.baseline --json
- [Agent 与脚本模式](agent-scripting.md)说明 JSON、退出码、timeout 和审计日志。
- [主机探测能力与本地插件](inspection-plugins.md)说明内置能力、`plugin create`、信任和观察快照。
- [SFTP 工作流](sftp.md)覆盖上传、下载、列目录、创建目录和删除。
- [受控文件 Apply](apply.md)用备份和哈希检查替换一个远程文件。
11 changes: 3 additions & 8 deletions docs/zh/sftp.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,11 @@
sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf
```

生产环境更安全的模式
覆盖已有远程文件并需要备份/哈希前置条件时,用 [受控文件 Apply](apply.md),不要自己拼 upload + `install`

```bash
# 先上传到临时路径
sshx -h=prod-web --upload=./deploy/nginx.conf --to=/tmp/nginx.conf

# 检查后再移动到正式位置
sshx -h=prod-web "sudo install -m 0644 /tmp/nginx.conf /etc/nginx/nginx.conf"
sshx -h=prod-web "sudo nginx -t"
sshx -h=prod-web "sudo systemctl reload nginx"
sshx apply -h=prod-web --path=/etc/nginx/nginx.conf --from=./deploy/nginx.conf --sudo --json
sshx run --target=prod-web --json -- "sudo nginx -t"
```

## 下载文件
Expand Down
7 changes: 7 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ func Run(args []string) (err error) {
return HandleSQL(config, audit)
}

if config.Mode == "apply" {
if config.Timeout == 0 {
config.Timeout = 60 * time.Second
}
return HandleApply(config, audit)
}

// Handle password management mode
if config.Mode == "password" {
if pwdErr := HandlePasswordManagement(config); pwdErr != nil {
Expand Down
Loading
Loading