diff --git a/AGENT.md b/AGENT.md
index bcab0ae..52baf15 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -38,6 +38,9 @@ The core value proposition:
password keys so one tool covers a whole fleet.
6. **Execution preview** — `--dry-run` explains the local execution plan without
connecting, executing, reading keyring secrets, or mutating state.
+7. **Auditability** — non-dry-run invocations write structured JSONL audit
+ events under `~/.sshx/audit` by default, with secrets and stdout/stderr
+ excluded.
## 3. Scope & Boundaries (Non-Goals)
@@ -77,6 +80,7 @@ internal/app/ → CLI surface (argument parsing, routing, sub-comman
password.go → keyring-backed password get/set/list + secure input
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
internal/sshclient/ → SSH/SFTP core
client.go → SSHClient: dial, auth, exec, SFTP, sudo-over-stdin
validate.go → command safety checks + CommandUsesSudo
@@ -103,6 +107,9 @@ pkg/logger/ → leveled logger (SSHX_LOG_LEVEL)
- **Secrets:** OS keyring under service name `sshx`
(macOS Keychain / Linux Secret Service / Windows Credential Manager).
- **Trust store:** `~/.ssh/known_hosts` (or `--known-hosts` / `SSH_KNOWN_HOSTS`).
+- **Audit trail:** `~/.sshx/audit/sshx-YYYY-MM-DD.jsonl` by default; override
+ with `--audit-output=
` / `SSHX_AUDIT_OUTPUT`, or disable with
+ `--no-audit` / `SSHX_NO_AUDIT=true`.
## 5. Tech Stack
@@ -235,7 +242,6 @@ A living, maintainer-adjustable plan. Items must respect the boundaries in §3.
**Long-term / under consideration**
-- ⬜ Optional structured audit log of executed commands.
- ⬜ Pluggable secret backends behind the existing keyring abstraction.
Anything implying a daemon, MCP, tunneling, or a GUI is explicitly **rejected**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5203ada..1988a8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Local structured audit events are written by default to
+ `~/.sshx/audit/sshx-YYYY-MM-DD.jsonl`. Use `--audit-output=` to choose a
+ directory or `--no-audit` / `SSHX_NO_AUDIT=true` to disable audit writing.
+ Audit events omit stdout/stderr and never store plaintext passwords or private
+ key contents.
- `--dry-run` prints a local execution plan without connecting, executing,
reading keyring secrets, mutating `known_hosts`, or writing local/remote
state. Combine with `--json` for agent-readable plan output.
diff --git a/README.md b/README.md
index 1e849ce..4b44f1f 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,8 @@ Managing multiple servers means juggling different passwords and repeatedly ente
2. Password management (Keychain / Secret Service / Credential Manager).
3. Host configuration management with per-host SSH keys.
4. Dry-run execution plan preview for humans and agents.
-5. Script execution and command security validation.
+5. Local structured audit trail with safe default redaction.
+6. Script execution and command security validation.
## Installation
@@ -266,6 +267,28 @@ sudo-key selection, safety-check result, and whether a real run would connect,
execute, read a secret, or mutate state. It does **not** prove the remote command
would succeed.
+### Local audit trail
+
+Every non-dry-run invocation writes one JSONL audit event by default:
+
+```text
+~/.sshx/audit/sshx-YYYY-MM-DD.jsonl
+```
+
+Use `--audit-output=` to place audit events next to a project, runbook, or
+incident record:
+
+```bash
+sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx"
+```
+
+Audit events record metadata and outcomes such as mode/action, host resolution,
+sudo/keyring decisions, safety status, auth method, exit code, and error kind.
+They do **not** record plaintext passwords, private key contents, or
+stdout/stderr. Command text is included for provenance but redacted for common
+password/token-style arguments. Use `--no-audit` or `SSHX_NO_AUDIT=true` to
+disable audit writing for a single invocation or environment.
+
### `--timeout` and `--pty`
```bash
@@ -549,6 +572,16 @@ export SUDO_PASSWORD=your_sudo_password
./bin/sshx "uptime"
```
+### Audit Environment Variables
+
+```bash
+# Write audit events to a project-specific directory
+export SSHX_AUDIT_OUTPUT=./.sshx-audit
+
+# Disable audit writing
+export SSHX_NO_AUDIT=true
+```
+
### SSH Authentication Preferences
- `sshx` now prioritizes SSH keys and automatically falls back to password authentication when the server rejects your key (for example when a host only allows passwords). As long as a password is available, the client will transparently retry with a password-only session.
diff --git a/README_CN.md b/README_CN.md
index fa4787e..1c1bed9 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -62,7 +62,8 @@ $$\ $$ |$$\ $$ |$$ | $$ |$$ /\$$\
2. 密码管理(Keychain / Secret Service / Credential Manager)。
3. 主机配置管理,支持为每台主机配置独立的 SSH 密钥。
4. 面向人和 agent 的 dry-run 执行计划预览。
-5. 脚本执行和命令安全验证。
+5. 本地结构化审计日志,并默认做安全脱敏。
+6. 脚本执行和命令安全验证。
## 安装
@@ -260,6 +261,24 @@ sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
dry-run 只是本地计划预览。它会说明主机解析、模式/动作、sudo key 选择、安全检查结果,
以及真实执行时是否会连接、执行、读取 secret 或修改状态;它不承诺远程命令一定成功。
+### 本地审计日志
+
+每次非 dry-run 调用都会默认写入一条 JSONL 审计事件:
+
+```text
+~/.sshx/audit/sshx-YYYY-MM-DD.jsonl
+```
+
+可以使用 `--audit-output=<目录>` 把审计事件保存到项目目录、运维记录或事故目录旁边:
+
+```bash
+sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx"
+```
+
+审计事件记录模式/动作、主机解析、sudo/keyring 决策、安全检查状态、认证方式、退出码和错误类型等元数据。
+它不会记录明文密码、私钥内容或 stdout/stderr。命令文本会作为溯源信息写入,但会对常见 password/token
+类参数做尽力脱敏。可以用 `--no-audit` 或 `SSHX_NO_AUDIT=true` 禁用单次调用或当前环境的审计写入。
+
### `--timeout` 与 `--pty`
```bash
@@ -467,6 +486,16 @@ export SUDO_PASSWORD=your_sudo_password
./bin/sshx "uptime"
```
+### 审计环境变量
+
+```bash
+# 将审计事件写到项目目录
+export SSHX_AUDIT_OUTPUT=./.sshx-audit
+
+# 禁用审计写入
+export SSHX_NO_AUDIT=true
+```
+
### SSH 认证偏好设置
- `sshx` 仍然会优先尝试 SSH 密钥认证,但如果服务器拒绝公钥(例如只允许密码登录),并且已经提供了密码,客户端会自动回退到“仅密码”重连,无需手动重试。
diff --git a/internal/app/agentmode_test.go b/internal/app/agentmode_test.go
index 0539c0c..7146523 100644
--- a/internal/app/agentmode_test.go
+++ b/internal/app/agentmode_test.go
@@ -129,6 +129,7 @@ func TestClassifyError(t *testing.T) {
// before any network work, so it reports error_kind "blocked" (not "connect")
// even though the host is never reachable.
func TestRun_BlockedCommandShortCircuits(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
diff --git a/internal/app/app.go b/internal/app/app.go
index 9e729e6..d6fcd17 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -71,6 +71,12 @@ func Run(args []string) (err error) {
// Parse command-line arguments
config := ParseArgs(args)
+ audit := newAuditRecorder(config)
+ defer func() {
+ if auditErr := audit.finish(config, err); auditErr != nil {
+ logger.GetLogger().Warning("failed to write audit event: %v", auditErr)
+ }
+ }()
if config.DryRun {
return emitDryRunPlan(config)
@@ -95,11 +101,11 @@ func Run(args []string) (err error) {
// Validate flags that only apply to command execution.
if config.Mode == "ssh" {
if config.Timeout < 0 {
- return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config",
+ return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config",
fmt.Errorf("invalid --timeout value (use e.g. 30s, 2m, or 30)"))
}
if config.JSONOutput && config.UsePTY {
- return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config",
+ return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config",
fmt.Errorf("--pty cannot be combined with --json (a PTY merges stderr into stdout)"))
}
@@ -108,7 +114,7 @@ func Run(args []string) (err error) {
// error_kind ("blocked") instead of being masked by a connect error.
if config.SafetyCheck && !config.Force {
if blockErr := sshclient.ValidateCommand(config.Command); blockErr != nil {
- return reportSSHFailure(config, sshclient.AuthMethodUnknown, classifyError(blockErr), blockErr)
+ return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, classifyError(blockErr), blockErr)
}
}
}
@@ -135,16 +141,19 @@ func Run(args []string) (err error) {
// Create SSH client
client, err := sshclient.NewSSHClient(config)
if err != nil {
- return reportSSHFailure(config, sshclient.AuthMethodUnknown, "config",
+ return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, "config",
fmt.Errorf("failed to create SSH client: %w", err))
}
defer errutil.HandleCloseError(&err, client)
// Connect to remote host (use direct connection for CLI mode, no need for pooling)
if err = client.ConnectDirect(); err != nil {
- return reportSSHFailure(config, sshclient.AuthMethodUnknown, classifyError(err),
+ return reportSSHFailure(config, audit, sshclient.AuthMethodUnknown, classifyError(err),
fmt.Errorf("failed to connect: %w", err))
}
+ if audit != nil {
+ audit.event.AuthMethod = string(client.AuthMethodUsed())
+ }
// Handle SFTP mode
if config.Mode == "sftp" {
@@ -155,16 +164,17 @@ func Run(args []string) (err error) {
}
// Handle SSH command execution
- return runCommand(client, config)
+ return runCommand(client, config, audit)
}
// runCommand runs the configured command and translates the result into either
// streamed human output or a single JSON object, then returns an error whose
// type tells the entry point which exit code to use.
-func runCommand(client *sshclient.SSHClient, config *sshclient.Config) error {
+func runCommand(client *sshclient.SSHClient, config *sshclient.Config, audit *auditRecorder) error {
start := time.Now()
res, execErr := client.RunCommand(config.JSONOutput)
dur := time.Since(start)
+ audit.recordCommandResult(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr)
if config.JSONOutput {
emitCommandJSON(config, client.AuthMethodUsed(), res, dur, classifyError(execErr), execErr)
@@ -189,7 +199,8 @@ func runCommand(client *sshclient.SSHClient, config *sshclient.Config) error {
// reportSSHFailure emits a JSON error object in --json command mode (and returns
// ErrReported so the caller exits silently), or returns the error unchanged for
// the normal streamed path.
-func reportSSHFailure(config *sshclient.Config, authMethod sshclient.AuthMethod, kind string, err error) error {
+func reportSSHFailure(config *sshclient.Config, audit *auditRecorder, authMethod sshclient.AuthMethod, kind string, err error) error {
+ audit.recordFailure(config, authMethod, kind, err)
if config.JSONOutput && config.Mode == "ssh" {
emitCommandJSON(config, authMethod, sshclient.ExecResult{ExitCode: -1}, 0, kind, err)
return ErrReported
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index e546ebb..831d5c8 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -115,6 +115,7 @@ func TestRun_ArgumentParsing(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
// Suppress output
oldStdout := os.Stdout
oldStderr := os.Stderr
diff --git a/internal/app/audit.go b/internal/app/audit.go
new file mode 100644
index 0000000..df70cb3
--- /dev/null
+++ b/internal/app/audit.go
@@ -0,0 +1,395 @@
+package app
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/talkincode/sshx/internal/sshclient"
+)
+
+const (
+ auditSchemaVersion = "sshx.audit.v1"
+ auditDirName = "audit"
+)
+
+type auditStatus struct {
+ Status string `json:"status"`
+ ErrorKind string `json:"error_kind,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+type auditRedaction struct {
+ SecretsRedacted bool `json:"secrets_redacted"`
+ StdoutOmitted bool `json:"stdout_omitted"`
+ StderrOmitted bool `json:"stderr_omitted"`
+}
+
+type auditEvent struct {
+ SchemaVersion string `json:"schema_version"`
+ EventID string `json:"event_id"`
+ Timestamp string `json:"timestamp"`
+ Version string `json:"version,omitempty"`
+ Actor string `json:"actor,omitempty"`
+ OS string `json:"os"`
+ Arch string `json:"arch"`
+
+ Mode string `json:"mode"`
+ Action string `json:"action,omitempty"`
+
+ HostInput string `json:"host_input,omitempty"`
+ HostResolved string `json:"host_resolved,omitempty"`
+ Port string `json:"port,omitempty"`
+ User string `json:"user,omitempty"`
+ HostName string `json:"host_name,omitempty"`
+ HostType string `json:"host_type,omitempty"`
+ HostDescSet bool `json:"host_description_set"`
+ HostResolvedBy string `json:"host_resolved_by,omitempty"`
+
+ Command string `json:"command,omitempty"`
+ SftpAction string `json:"sftp_action,omitempty"`
+ LocalPath string `json:"local_path,omitempty"`
+ RemotePath string `json:"remote_path,omitempty"`
+
+ UseKeyAuth bool `json:"use_key_auth"`
+ KeyPath string `json:"key_path,omitempty"`
+ PasswordProvided bool `json:"password_provided"`
+ PasswordValueProvided bool `json:"password_value_provided"`
+ PasswordKey string `json:"password_key,omitempty"`
+ UsesSudo bool `json:"uses_sudo"`
+ SudoKey string `json:"sudo_key,omitempty"`
+
+ Timeout string `json:"timeout,omitempty"`
+ JSONOutput bool `json:"json_output"`
+ UsePTY bool `json:"pty"`
+ SafetyCheckEnabled bool `json:"safety_check_enabled"`
+ Force bool `json:"force"`
+ AcceptUnknownHost bool `json:"accept_unknown_host"`
+ AllowInsecureHostKey bool `json:"allow_insecure_host_key"`
+ KnownHostsPath string `json:"known_hosts_path,omitempty"`
+
+ WouldReadSecret bool `json:"would_read_secret"`
+ WouldWriteLocalState bool `json:"would_write_local_state"`
+ WouldMutateRemote bool `json:"would_mutate_remote"`
+ MayMutateKnownHosts bool `json:"may_mutate_known_hosts"`
+
+ AuthMethod string `json:"auth_method,omitempty"`
+ ExitCode *int `json:"exit_code,omitempty"`
+ DurationMs int64 `json:"duration_ms"`
+ Outcome auditStatus `json:"outcome"`
+ Redaction auditRedaction `json:"redaction"`
+}
+
+type auditRecorder struct {
+ started time.Time
+ event auditEvent
+ completed bool
+}
+
+var (
+ sensitiveAssignmentRE = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=([^\s&;]+)`)
+ sensitiveFlagRE = regexp.MustCompile(`(?i)(--(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))([^\s]+)`)
+)
+
+func newAuditRecorder(config *sshclient.Config) *auditRecorder {
+ if config == nil || !config.AuditEnabled || config.DryRun {
+ return nil
+ }
+
+ started := time.Now()
+ return &auditRecorder{
+ started: started,
+ event: auditEvent{
+ SchemaVersion: auditSchemaVersion,
+ EventID: newAuditEventID(),
+ Timestamp: started.UTC().Format(time.RFC3339Nano),
+ Version: Version,
+ Actor: currentActor(),
+ OS: runtime.GOOS,
+ Arch: runtime.GOARCH,
+ HostInput: config.Host,
+ Outcome: auditStatus{Status: "started"},
+ Redaction: auditRedaction{
+ SecretsRedacted: true,
+ StdoutOmitted: true,
+ StderrOmitted: true,
+ },
+ },
+ }
+}
+
+func (r *auditRecorder) recordCommandResult(config *sshclient.Config, authMethod sshclient.AuthMethod, res sshclient.ExecResult, dur time.Duration, errKind string, execErr error) {
+ if r == nil {
+ return
+ }
+ r.refresh(config)
+ r.event.AuthMethod = string(authMethod)
+ r.event.DurationMs = dur.Milliseconds()
+ r.event.ExitCode = intPtr(res.ExitCode)
+ if execErr != nil {
+ r.event.Outcome = auditStatus{
+ Status: "failure",
+ ErrorKind: errKind,
+ Message: redactSensitiveText(execErr.Error()),
+ }
+ r.completed = true
+ return
+ }
+ if res.ExitCode != 0 {
+ r.event.Outcome = auditStatus{
+ Status: "failure",
+ ErrorKind: "remote_exit",
+ Message: fmt.Sprintf("command exited with status %d", res.ExitCode),
+ }
+ r.completed = true
+ return
+ }
+ r.event.Outcome = auditStatus{Status: "success"}
+ r.completed = true
+}
+
+func (r *auditRecorder) recordFailure(config *sshclient.Config, authMethod sshclient.AuthMethod, kind string, err error) {
+ if r == nil {
+ return
+ }
+ r.refresh(config)
+ switch kind {
+ case "blocked", "config":
+ r.event.WouldReadSecret = false
+ r.event.WouldMutateRemote = false
+ r.event.MayMutateKnownHosts = false
+ case "connect", "auth", "host_key":
+ r.event.WouldMutateRemote = false
+ }
+ r.event.AuthMethod = string(authMethod)
+ r.event.ExitCode = intPtr(-1)
+ r.event.Outcome = auditStatus{
+ Status: "failure",
+ ErrorKind: kind,
+ Message: redactError(err),
+ }
+ r.completed = true
+}
+
+func (r *auditRecorder) finish(config *sshclient.Config, err error) error {
+ if r == nil {
+ return nil
+ }
+ if !r.completed {
+ r.refresh(config)
+ r.event.DurationMs = time.Since(r.started).Milliseconds()
+ var exitErr *ExitError
+ switch {
+ case err == nil:
+ r.event.Outcome = auditStatus{Status: "success"}
+ case errors.As(err, &exitErr):
+ r.event.ExitCode = intPtr(exitErr.Code)
+ r.event.Outcome = auditStatus{
+ Status: "failure",
+ ErrorKind: "remote_exit",
+ Message: exitErr.Error(),
+ }
+ default:
+ r.event.Outcome = auditStatus{
+ Status: "failure",
+ ErrorKind: classifyError(err),
+ Message: redactError(err),
+ }
+ }
+ r.completed = true
+ }
+ return writeAuditEvent(config, r.event, r.started)
+}
+
+func (r *auditRecorder) refresh(config *sshclient.Config) {
+ if r == nil || config == nil {
+ return
+ }
+ r.event.Mode = config.Mode
+ r.event.Action = auditAction(config)
+ r.event.HostResolved = config.Host
+ r.event.Port = config.Port
+ r.event.User = config.User
+ r.event.HostName = config.HostName
+ r.event.HostType = config.HostType
+ r.event.HostDescSet = config.HostDescription != ""
+ if r.event.HostInput != "" && r.event.HostResolved != "" && r.event.HostResolved != r.event.HostInput {
+ r.event.HostResolvedBy = "settings"
+ }
+ r.event.Command = redactSensitiveText(config.Command)
+ r.event.SftpAction = config.SftpAction
+ r.event.LocalPath = config.LocalPath
+ r.event.RemotePath = config.RemotePath
+ r.event.UseKeyAuth = config.UseKeyAuth
+ r.event.KeyPath = config.KeyPath
+ r.event.PasswordProvided = config.Password != ""
+ r.event.PasswordValueProvided = config.PasswordValue != ""
+ r.event.PasswordKey = config.PasswordKey
+ r.event.UsesSudo = sshclient.CommandUsesSudo(config.Command)
+ r.event.SudoKey = config.SudoKey
+ if config.Timeout > 0 {
+ r.event.Timeout = config.Timeout.String()
+ }
+ r.event.JSONOutput = config.JSONOutput
+ r.event.UsePTY = config.UsePTY
+ r.event.SafetyCheckEnabled = config.SafetyCheck
+ r.event.Force = config.Force
+ r.event.AcceptUnknownHost = config.AcceptUnknownHost
+ r.event.AllowInsecureHostKey = config.AllowInsecureHostKey
+ r.event.KnownHostsPath = config.KnownHostsPath
+ r.event.WouldReadSecret = auditWouldReadSecret(config)
+ r.event.WouldWriteLocalState = auditWouldWriteLocalState(config)
+ r.event.WouldMutateRemote = auditWouldMutateRemote(config)
+ r.event.MayMutateKnownHosts = modeUsesSSHConnection(config) && config.AcceptUnknownHost
+ if r.event.DurationMs == 0 {
+ r.event.DurationMs = time.Since(r.started).Milliseconds()
+ }
+}
+
+func writeAuditEvent(config *sshclient.Config, event auditEvent, now time.Time) error {
+ dir, err := auditOutputDir(config)
+ if err != nil {
+ return err
+ }
+ if mkdirErr := os.MkdirAll(dir, 0o700); mkdirErr != nil {
+ return fmt.Errorf("failed to create audit directory %s: %w", dir, mkdirErr)
+ }
+ path := filepath.Join(dir, fmt.Sprintf("sshx-%s.jsonl", now.Format("2006-01-02")))
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) // #nosec G304 -- audit path is user-configurable by design.
+ if err != nil {
+ return fmt.Errorf("failed to open audit log %s: %w", path, err)
+ }
+ defer func() { _ = file.Close() }() //nolint:errcheck // best effort after append
+
+ enc := json.NewEncoder(file)
+ enc.SetEscapeHTML(false)
+ if err := enc.Encode(event); err != nil {
+ return fmt.Errorf("failed to write audit event: %w", err)
+ }
+ return nil
+}
+
+func auditOutputDir(config *sshclient.Config) (string, error) {
+ if config != nil && config.AuditOutput != "" {
+ return expandHome(config.AuditOutput)
+ }
+ settingsDir, err := GetSettingsDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(settingsDir, auditDirName), nil
+}
+
+func expandHome(path string) (string, error) {
+ if path == "" || path[0] != '~' {
+ return path, nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get home directory: %w", err)
+ }
+ if path == "~" {
+ return home, nil
+ }
+ if strings.HasPrefix(path, "~/") {
+ return filepath.Join(home, path[2:]), nil
+ }
+ return path, nil
+}
+
+func auditAction(config *sshclient.Config) string {
+ switch config.Mode {
+ case "ssh":
+ return "command"
+ case "sftp":
+ return config.SftpAction
+ case "password":
+ return config.PasswordAction
+ case "host":
+ return config.HostAction
+ default:
+ return ""
+ }
+}
+
+func auditWouldReadSecret(config *sshclient.Config) bool {
+ switch config.Mode {
+ case "ssh":
+ return sshclient.CommandUsesSudo(config.Command) && config.SudoKey != ""
+ case "password":
+ return config.PasswordAction == "get" || config.PasswordAction == "check" || config.PasswordAction == "delete" || config.PasswordAction == "list"
+ case "host":
+ return config.HostAction == "test" || config.HostAction == "test-all"
+ default:
+ return false
+ }
+}
+
+func auditWouldWriteLocalState(config *sshclient.Config) bool {
+ switch config.Mode {
+ case "password":
+ return config.PasswordAction == "set" || config.PasswordAction == "delete"
+ case "host":
+ return config.HostAction == "add" || config.HostAction == "update" || config.HostAction == "remove"
+ default:
+ return false
+ }
+}
+
+func auditWouldMutateRemote(config *sshclient.Config) bool {
+ switch config.Mode {
+ case "ssh":
+ return config.Command != ""
+ case "sftp":
+ return config.SftpAction == "upload" || config.SftpAction == "mkdir" || config.SftpAction == "remove"
+ case "host":
+ return config.HostAction == "test" || config.HostAction == "test-all"
+ default:
+ return false
+ }
+}
+
+func redactSensitiveText(value string) string {
+ if value == "" {
+ return ""
+ }
+ value = sensitiveAssignmentRE.ReplaceAllString(value, "$1=")
+ value = sensitiveFlagRE.ReplaceAllString(value, "$1")
+ return value
+}
+
+func redactError(err error) string {
+ if err == nil {
+ return ""
+ }
+ return redactSensitiveText(err.Error())
+}
+
+func newAuditEventID() string {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err == nil {
+ return hex.EncodeToString(b[:])
+ }
+ return fmt.Sprintf("%d", time.Now().UnixNano())
+}
+
+func currentActor() string {
+ for _, key := range []string{"USER", "USERNAME", "LOGNAME"} {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func intPtr(value int) *int {
+ return &value
+}
diff --git a/internal/app/audit_test.go b/internal/app/audit_test.go
new file mode 100644
index 0000000..ef2f181
--- /dev/null
+++ b/internal/app/audit_test.go
@@ -0,0 +1,191 @@
+package app
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/talkincode/sshx/internal/sshclient"
+)
+
+func TestRun_BlockedCommandWritesRedactedAuditEvent(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+ auditDir := t.TempDir()
+ command := "sudo rm -rf / password=orange --token purple" //nolint:gosec // test verifies redaction of credential-like arguments.
+
+ old := os.Stdout
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("failed to create pipe: %v", err)
+ }
+ os.Stdout = w
+
+ runErr := Run([]string{"sshx", "-h=192.0.2.1", "--audit-output=" + auditDir, "--json", command})
+
+ if closeErr := w.Close(); closeErr != nil {
+ t.Logf("failed to close pipe writer: %v", closeErr)
+ }
+ os.Stdout = old
+ if _, copyErr := io.Copy(io.Discard, r); copyErr != nil {
+ t.Logf("failed to drain stdout: %v", copyErr)
+ }
+
+ if !errors.Is(runErr, ErrReported) {
+ t.Fatalf("expected ErrReported, got %v", runErr)
+ }
+
+ event := readSingleAuditEvent(t, auditDir)
+ if event["schema_version"] != auditSchemaVersion {
+ t.Fatalf("expected schema %q, got %v", auditSchemaVersion, event["schema_version"])
+ }
+ if event["mode"] != "ssh" {
+ t.Errorf("expected ssh mode, got %v", event["mode"])
+ }
+ if event["action"] != "command" {
+ t.Errorf("expected command action, got %v", event["action"])
+ }
+ if event["host_input"] != "192.0.2.1" {
+ t.Errorf("expected host input, got %v", event["host_input"])
+ }
+ if event["uses_sudo"] != true {
+ t.Errorf("expected uses_sudo=true, got %v", event["uses_sudo"])
+ }
+ if event["would_read_secret"] != false {
+ t.Errorf("blocked command must not audit a secret read, got %v", event["would_read_secret"])
+ }
+ if event["would_mutate_remote"] != false {
+ t.Errorf("blocked command must not audit remote mutation, got %v", event["would_mutate_remote"])
+ }
+
+ auditedCommand, ok := event["command"].(string)
+ if !ok {
+ t.Fatalf("expected command string, got %T", event["command"])
+ }
+ if strings.Contains(auditedCommand, "orange") || strings.Contains(auditedCommand, "purple") {
+ t.Fatalf("audit command was not redacted: %q", auditedCommand)
+ }
+ if !strings.Contains(auditedCommand, "password=") || !strings.Contains(auditedCommand, "--token ") {
+ t.Errorf("audit command did not include expected redaction markers: %q", auditedCommand)
+ }
+
+ outcome, ok := event["outcome"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected outcome object, got %T", event["outcome"])
+ }
+ if outcome["status"] != "failure" {
+ t.Errorf("expected failure outcome, got %v", outcome["status"])
+ }
+ if outcome["error_kind"] != "blocked" {
+ t.Errorf("expected blocked error kind, got %v", outcome["error_kind"])
+ }
+ message, ok := outcome["message"].(string)
+ if !ok {
+ t.Fatalf("expected outcome message string, got %T", outcome["message"])
+ }
+ if strings.Contains(message, "orange") || strings.Contains(message, "purple") {
+ t.Fatalf("audit error message was not redacted: %q", message)
+ }
+
+ redaction, ok := event["redaction"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected redaction object, got %T", event["redaction"])
+ }
+ if redaction["secrets_redacted"] != true || redaction["stdout_omitted"] != true || redaction["stderr_omitted"] != true {
+ t.Errorf("unexpected redaction metadata: %v", redaction)
+ }
+ if _, exists := event["stdout"]; exists {
+ t.Error("audit event must not include stdout")
+ }
+ if _, exists := event["stderr"]; exists {
+ t.Error("audit event must not include stderr")
+ }
+}
+
+func TestRun_DryRunDoesNotWriteAuditEvent(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+ auditDir := filepath.Join(t.TempDir(), "audit")
+ result := runDryRunJSON(t, []string{"sshx", "-h=192.0.2.1", "--audit-output=" + auditDir, "--dry-run", "--json", "uptime"})
+
+ if result["dry_run"] != true {
+ t.Fatalf("expected dry_run=true, got %v", result["dry_run"])
+ }
+ if _, err := os.Stat(auditDir); !os.IsNotExist(err) {
+ t.Fatalf("dry-run should not create audit directory, stat err=%v", err)
+ }
+}
+
+func TestWriteAuditEventUsesJSONLWithPrivatePermissions(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ config := &sshclient.Config{AuditEnabled: true}
+ event := auditEvent{
+ SchemaVersion: auditSchemaVersion,
+ EventID: "test-event",
+ Timestamp: "2026-06-20T00:00:00Z",
+ Mode: "ssh",
+ Action: "command",
+ Outcome: auditStatus{Status: "success"},
+ Redaction: auditRedaction{SecretsRedacted: true, StdoutOmitted: true, StderrOmitted: true},
+ }
+
+ if err := writeAuditEvent(config, event, mustParseDate(t, "2026-06-20")); err != nil {
+ t.Fatalf("writeAuditEvent() error = %v", err)
+ }
+
+ auditPath := filepath.Join(home, SettingsDir, auditDirName, "sshx-2026-06-20.jsonl")
+ info, err := os.Stat(auditPath)
+ if err != nil {
+ t.Fatalf("expected audit file at %s: %v", auditPath, err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ t.Fatalf("expected audit file mode 0600, got %v", info.Mode().Perm())
+ }
+
+ data, err := os.ReadFile(auditPath) //nolint:gosec // test reads a controlled temp file.
+ if err != nil {
+ t.Fatalf("failed to read audit file: %v", err)
+ }
+ if lines := bytes.Count(data, []byte("\n")); lines != 1 {
+ t.Fatalf("expected one JSONL line, got %d in %q", lines, string(data))
+ }
+}
+
+func readSingleAuditEvent(t *testing.T, auditDir string) map[string]any {
+ t.Helper()
+
+ entries, err := os.ReadDir(auditDir)
+ if err != nil {
+ t.Fatalf("failed to read audit directory: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("expected one audit file, got %d", len(entries))
+ }
+ data, err := os.ReadFile(filepath.Join(auditDir, entries[0].Name())) //nolint:gosec // test reads a controlled temp file.
+ if err != nil {
+ t.Fatalf("failed to read audit file: %v", err)
+ }
+ lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
+ if len(lines) != 1 {
+ t.Fatalf("expected one audit event, got %d", len(lines))
+ }
+ var event map[string]any
+ if err := json.Unmarshal(lines[0], &event); err != nil {
+ t.Fatalf("failed to decode audit event %q: %v", string(lines[0]), err)
+ }
+ return event
+}
+
+func mustParseDate(t *testing.T, value string) time.Time {
+ t.Helper()
+ parsed, err := time.Parse("2006-01-02", value)
+ if err != nil {
+ t.Fatalf("failed to parse date: %v", err)
+ }
+ return parsed
+}
diff --git a/internal/app/config.go b/internal/app/config.go
index 324461e..3f14bb4 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -35,10 +35,11 @@ func parseTimeout(value string) (time.Duration, error) {
// ParseArgs parses command-line arguments and returns a Config.
func ParseArgs(args []string) *sshclient.Config {
config := &sshclient.Config{
- Mode: "ssh",
- SafetyCheck: true,
- Force: false,
- UseKeyAuth: true,
+ Mode: "ssh",
+ SafetyCheck: true,
+ Force: false,
+ UseKeyAuth: true,
+ AuditEnabled: true,
}
if password := os.Getenv("SSH_PASSWORD"); password != "" {
@@ -54,6 +55,12 @@ func ParseArgs(args []string) *sshclient.Config {
if knownHosts := os.Getenv("SSH_KNOWN_HOSTS"); knownHosts != "" {
config.KnownHostsPath = knownHosts
}
+ if auditOutput := os.Getenv("SSHX_AUDIT_OUTPUT"); auditOutput != "" {
+ config.AuditOutput = auditOutput
+ }
+ if noAudit := os.Getenv("SSHX_NO_AUDIT"); strings.EqualFold(noAudit, "true") || noAudit == "1" {
+ config.AuditEnabled = false
+ }
if acceptUnknown := os.Getenv("SSH_ACCEPT_UNKNOWN_HOST"); strings.EqualFold(acceptUnknown, "true") || acceptUnknown == "1" {
config.AcceptUnknownHost = true
}
@@ -114,6 +121,10 @@ func ParseArgs(args []string) *sshclient.Config {
config.SafetyCheck = false
case arg == "--dry-run":
config.DryRun = true
+ case strings.HasPrefix(arg, "--audit-output="):
+ config.AuditOutput = strings.SplitN(arg, "=", 2)[1]
+ case arg == "--no-audit":
+ config.AuditEnabled = false
case arg == "--json":
config.JSONOutput = true
case arg == "--pty":
diff --git a/internal/app/config_test.go b/internal/app/config_test.go
index e914fc0..641f6b8 100644
--- a/internal/app/config_test.go
+++ b/internal/app/config_test.go
@@ -148,6 +148,21 @@ func TestParseArgs_DryRun(t *testing.T) {
}
}
+func TestParseArgs_AuditOptions(t *testing.T) {
+ config := ParseArgs([]string{"sshx", "-h=host", "--audit-output=/tmp/sshx-audit", "uptime"})
+ if !config.AuditEnabled {
+ t.Error("Expected audit to be enabled by default")
+ }
+ if config.AuditOutput != "/tmp/sshx-audit" {
+ t.Errorf("Expected audit output path, got %q", config.AuditOutput)
+ }
+
+ config = ParseArgs([]string{"sshx", "-h=host", "--no-audit", "uptime"})
+ if config.AuditEnabled {
+ t.Error("Expected audit to be disabled by --no-audit")
+ }
+}
+
func TestParseArgs_SFTPUpload(t *testing.T) {
args := []string{"sshx", "-h=host", "--upload=local.txt", "--to=/remote/path.txt"}
config := ParseArgs(args)
diff --git a/internal/app/usage.go b/internal/app/usage.go
index 46479ac..dbcd265 100644
--- a/internal/app/usage.go
+++ b/internal/app/usage.go
@@ -33,6 +33,8 @@ SSH Options:
-pk, --password-key=KEY Sudo password keyring key name (default: master)
Used only when the remote command starts with sudo
--dry-run Print the local execution plan without side effects
+ --audit-output=DIR Write audit JSONL files to DIR (default: ~/.sshx/audit)
+ --no-audit Disable local audit event writing for this invocation
--timeout=DURATION Command execution timeout (e.g. 30s, 2m, or 30 = seconds)
--json Emit a single structured JSON result on stdout
--pty Request a PTY (merges stderr into stdout; off by default)
@@ -82,6 +84,17 @@ Dry-run Plan Preview:
Dry-run is a local plan preview only. It does not prove the remote command
would succeed.
+Audit Trail:
+ sshx writes one structured JSONL audit event per non-dry-run invocation to
+ ~/.sshx/audit/sshx-YYYY-MM-DD.jsonl by default. Use --audit-output= to
+ save audit events next to a project or incident record.
+
+ Audit events record metadata and outcomes such as mode/action, host
+ resolution, sudo/keyring decisions, safety status, auth method, exit code, and
+ error kind. They do not record plaintext passwords, private key contents, or
+ stdout/stderr. Command text is best-effort redacted for password/token-style
+ arguments.
+
Safety Options:
-f, --force Force execution, bypass safety checks (use with caution!)
--no-safety-check Disable safety checks completely (not recommended)
@@ -141,6 +154,8 @@ Environment Variables (.env):
SSH_NO_SAFETY_CHECK Disable safety checks (true/false)
SSH_FORCE Force execution mode (true/false)
SSH_TIMEOUT Command execution timeout (e.g. 30s, 2m, or 30 = seconds)
+ SSHX_AUDIT_OUTPUT Audit output directory (default: ~/.sshx/audit)
+ SSHX_NO_AUDIT Disable audit writing (true/false)
SSH Examples:
# Execute simple command (default user: master)
@@ -162,6 +177,9 @@ SSH Examples:
# Preview the execution plan without connecting or reading secrets
sshx -h=prod-web --dry-run --json "sudo systemctl restart nginx"
+ # Save audit events for this project
+ sshx -h=prod-web --audit-output=./.sshx-audit "systemctl reload nginx"
+
# Bound a command with a timeout (kills it after 30s)
sshx -h=192.168.1.100 --timeout=30s "apt-get update"
@@ -268,6 +286,7 @@ Note:
- SSH key authentication is tried first, then password authentication
- Sudo password is auto-filled only when the remote command starts with sudo
- Dry-run never connects, executes, reads keyring secrets, or writes state
+ - Audit events are JSONL files under ~/.sshx/audit by default
- SFTP operations use the same SSH connection
- Password manager works across macOS/Linux/Windows
- Default user: master, Default sudo key: master
diff --git a/internal/app/usage_test.go b/internal/app/usage_test.go
index 7ddb029..07a4bb4 100644
--- a/internal/app/usage_test.go
+++ b/internal/app/usage_test.go
@@ -40,6 +40,7 @@ func TestPrintUsage(t *testing.T) {
"SSH Options:",
"Sudo Auto-fill:",
"Dry-run Plan Preview:",
+ "Audit Trail:",
"Safety Options:",
"SFTP Options:",
"Password Management",
@@ -63,6 +64,7 @@ func TestPrintUsage(t *testing.T) {
"--password-set=",
"--password-get=",
"--dry-run",
+ "--audit-output",
"--force",
"--no-safety-check",
}
@@ -89,6 +91,7 @@ func TestPrintUsage(t *testing.T) {
"remote command starts with sudo",
"Non-leading sudo is not auto-filled",
"Dry-run never connects",
+ "Audit events are JSONL",
}
for _, keyword := range safetyKeywords {
@@ -165,6 +168,7 @@ func TestPrintUsage_Examples(t *testing.T) {
`sshx --password-set=master`,
`--upload=local.txt --to=/tmp/remote.txt`,
`--download=/var/log/app.log`,
+ `--audit-output=./.sshx-audit`,
}
for _, example := range examples {
diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go
index 9defe0b..55005ab 100644
--- a/internal/sshclient/client.go
+++ b/internal/sshclient/client.go
@@ -64,6 +64,10 @@ type Config struct {
// DryRun emits a local execution plan without connecting, executing, reading
// keyring secrets, or mutating local/remote state.
DryRun bool
+ // AuditEnabled controls whether sshx writes a local structured audit event.
+ AuditEnabled bool
+ // AuditOutput overrides the directory where audit JSONL files are written.
+ AuditOutput string
SafetyCheck bool
Force bool
diff --git a/skills/sshx/SKILL.md b/skills/sshx/SKILL.md
index 380d0a8..5eef230 100644
--- a/skills/sshx/SKILL.md
+++ b/skills/sshx/SKILL.md
@@ -56,6 +56,25 @@ Dry-run reports host resolution, mode/action, sudo key selection, safety-check
status, and whether a real run would connect, execute, read a secret, or mutate
state. It does not simulate remote command success.
+## Audit trail
+
+Every non-dry-run invocation writes one JSONL audit event by default:
+`~/.sshx/audit/sshx-YYYY-MM-DD.jsonl`.
+
+Use `--audit-output=` when the audit record should live with a project or
+incident folder:
+
+```bash
+sshx -h=prod-web --audit-output=./.sshx-audit --json "systemctl reload nginx"
+```
+
+Audit events record metadata and outcomes such as mode/action, host resolution,
+sudo/keyring decisions, safety status, auth method, exit code, and error kind.
+They do not record plaintext passwords, private key contents, or stdout/stderr.
+Command text is included but best-effort redacted for password/token-style
+arguments. Use `--no-audit` only when the user explicitly wants no local audit
+event for that invocation.
+
## Exit codes (and how to read failures)
| Exit code | Meaning |