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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ For Codex, and for Claude Code sessions without the mod, the ShellTime server lo

- **Data masking** (`dataMasking`, on by default) redacts sensitive command content before it leaves your machine.
- **Exclusion patterns** skip matching commands entirely, so they are never recorded.
- **Encryption** (`encrypted: true`, set in the config `shelltime auth` creates) makes the daemon encrypt command uploads with your token's public key (RSA + AES-GCM). It only applies in daemon mode, and if the key can't be fetched the upload fails instead of going out unencrypted.
- **Encryption** (`encrypted: true`, set in the config `shelltime auth` creates) makes the daemon encrypt command uploads with your token's public key (RSA + AES-GCM). It only applies in daemon mode. If the key can't be fetched, is empty, or encryption fails, the upload fails instead of going out unencrypted or with a key the server cannot unwrap.
- **`shelltime auth`** writes `~/.shelltime` as mode `0700` and `config.yaml` as mode `0600`.
- **AI coding telemetry** includes prompts and tool details unless you turn them off (see [Claude Code and Codex Telemetry](#claude-code-and-codex-telemetry)). `--no-prompts` keeps prompt text out of a backfill.
- **Local config overrides** keep secrets like tokens out of your main config file.

Expand Down
23 changes: 20 additions & 3 deletions commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,17 @@ func commandAuth(c *cli.Context) error {
defer span.End()
configDir := os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
err = os.Mkdir(configDir, 0755)
err = os.Mkdir(configDir, 0700)
if err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
} else if err != nil {
return fmt.Errorf("failed to stat config directory: %w", err)
}
// The directory holds the API token. 0755 lets other users on the machine
// traverse it and read a world-readable config.yaml.
if err := os.Chmod(configDir, 0700); err != nil {
return fmt.Errorf("failed to restrict config directory permissions: %w", err)
}
SetupLogger(configDir)

Expand All @@ -89,7 +96,7 @@ func commandAuth(c *cli.Context) error {
// Prepend $schema for IDE autocompletion support
schemaHeader := "# yaml-language-server: $schema=./config-schema.json\n"
content = append([]byte(schemaHeader), content...)
err = os.WriteFile(configFile, content, 0644)
err = writeTokenConfig(configFile, content)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
Expand Down Expand Up @@ -121,7 +128,7 @@ func commandAuth(c *cli.Context) error {
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
err = os.WriteFile(configFile, content, 0644)
err = writeTokenConfig(configFile, content)
if err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
Expand Down Expand Up @@ -179,6 +186,16 @@ func ApplyTokenByHandshake(_ctx context.Context, config model.ShellTimeConfig) (
}
}

// writeTokenConfig writes the config that contains the API token and forces
// owner-only permissions. WriteFile does not change the mode of a file that
// already exists, so a previous 0644 config would stay world-readable.
func writeTokenConfig(path string, content []byte) error {
if err := os.WriteFile(path, content, 0600); err != nil {
return err
}
return os.Chmod(path, 0600)
}

// ApplyTokenByAppApproval pushes a sign-in request to the ShellTime iOS app of
// the account with this email and waits for the user to approve it. Output is
// plain lines, so an agent driving the CLI can relay the code to the user.
Expand Down
47 changes: 47 additions & 0 deletions commands/auth_perm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package commands

import (
"os"
"path/filepath"
"testing"

"github.com/malamtime/cli/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace/noop"
)

func TestCommandAuth_RestrictsConfigPermissions(t *testing.T) {
otel.SetTracerProvider(noop.NewTracerProvider())
prevSkip := SKIP_LOGGER_SETTINGS
SKIP_LOGGER_SETTINGS = true
t.Cleanup(func() { SKIP_LOGGER_SETTINGS = prevSkip })

home := t.TempDir()
t.Setenv("HOME", home)
dir := filepath.Join(home, ".shelltime")
require.NoError(t, os.Mkdir(dir, 0o755))
cfgPath := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(cfgPath, []byte("token: old\napiEndpoint: https://api.shelltime.xyz\nwebEndpoint: https://shelltime.xyz\n"), 0o644))

orig := configService
configService = model.NewConfigService(dir)
t.Cleanup(func() { configService = orig })

app := &cli.App{Name: "t", Commands: []*cli.Command{AuthCommand}}
require.NoError(t, app.Run([]string{"t", "auth", "--token", "secret-token"}))

info, err := os.Stat(cfgPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
dirInfo, err := os.Stat(dir)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())

data, err := os.ReadFile(cfgPath)
require.NoError(t, err)
assert.Contains(t, string(data), "secret-token")
assert.NotContains(t, string(data), "token: old")
}
2 changes: 1 addition & 1 deletion commands/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func backupAndWriteFile(filePath string, content []byte) error {
}
}

if err := os.WriteFile(filePath, content, 0644); err != nil {
if err := model.WritePrivateFile(filePath, content); err != nil {
slog.Warn("failed to write file", slog.String("file", filePath), slog.Any("err", err))
return fmt.Errorf("failed to write file %s: %w", filePath, err)
}
Expand Down
9 changes: 7 additions & 2 deletions commands/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func SetupLogger(baseFolder string) {
logFilePath := baseFolder + "/log.log"

if _, err := os.Stat(logFilePath); os.IsNotExist(err) {
err := os.MkdirAll(baseFolder, 0755)
err := os.MkdirAll(baseFolder, 0700)
if err != nil {
slog.Error("[ShellTime.xyz] failed to create log directory", slog.Any("err", err))
return
Expand All @@ -29,8 +29,13 @@ func SetupLogger(baseFolder string) {
return
}
}
// Logs can echo command lines. Tighten a world-readable file once; skip
// when it is already private so the per-command track path stays cheap.
if st, err := os.Stat(logFilePath); err == nil && st.Mode().Perm()&0o077 != 0 {
_ = os.Chmod(logFilePath, 0600)
}

f, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0755)
f, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
if err != nil {
os.Stdout.WriteString(err.Error())
slog.Error("[ShellTime.xyz] on setup logger error", slog.Any("err", err))
Expand Down
36 changes: 31 additions & 5 deletions commands/track.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ var TrackCommand *cli.Command = &cli.Command{
Name: "sessionId",
Aliases: []string{"id"},
Value: 0,
Usage: "unix timestamp of the session",
Usage: "session identifier shared by the pre and post events (hooks pass a per-shell id)",
},
&cli.StringFlag{
Name: "command",
Expand Down Expand Up @@ -99,8 +99,10 @@ func commandTrack(c *cli.Context) error {
// default socket, hand it the raw event (fire-and-forget) and return. The daemon
// is a long-lived process: it reads config once (cached) and owns the
// storage-engine decision (bolt vs txt), exclude filtering, sync and pruning.
if daemon.IsSocketReady(ctx, model.DefaultSocketPath) {
return sendTrackEventToDaemon(ctx, span, model.DefaultSocketPath, cmdPhase, instance, result)
// A socket file left behind by a dead daemon still stats as present; if the
// send fails, fall through and persist locally instead of dropping the command.
if forwarded, err := forwardTrackToDaemon(ctx, span, model.DefaultSocketPath, cmdPhase, instance, result); forwarded || err != nil {
return err
}

// Slow path: no daemon on the default socket. We can now afford to read config —
Expand All @@ -119,8 +121,10 @@ func commandTrack(c *cli.Context) error {
}

// A daemon may still be listening on a non-default socket path.
if config.SocketPath != model.DefaultSocketPath && daemon.IsSocketReady(ctx, config.SocketPath) {
return sendTrackEventToDaemon(ctx, span, config.SocketPath, cmdPhase, instance, result)
if config.SocketPath != model.DefaultSocketPath {
if forwarded, err := forwardTrackToDaemon(ctx, span, config.SocketPath, cmdPhase, instance, result); forwarded || err != nil {
return err
}
}

// No daemon at all: persist to the local txt store and sync directly over HTTP.
Expand All @@ -146,6 +150,21 @@ func commandTrack(c *cli.Context) error {
return nil
}

// forwardTrackToDaemon reports whether the event was accepted by a daemon on
// socketPath. A missing socket is not an error. A present socket that refuses
// the connection (stale socket file) is logged and reported as not forwarded
// so the caller can persist the command itself.
func forwardTrackToDaemon(ctx context.Context, span trace.Span, socketPath, cmdPhase string, instance *model.Command, result int) (bool, error) {
if !daemon.IsSocketReady(ctx, socketPath) {
return false, nil
}
if err := sendTrackEventToDaemon(ctx, span, socketPath, cmdPhase, instance, result); err != nil {
slog.Warn("failed to send track event to daemon; saving locally", slog.String("socket", socketPath), slog.Any("err", err))
return false, nil
}
return true, nil
}

// sendTrackEventToDaemon forwards a single raw pre/post command event to the
// daemon. The daemon owns persistence, exclude filtering, sync and pruning.
func sendTrackEventToDaemon(ctx context.Context, span trace.Span, socketPath, cmdPhase string, instance *model.Command, result int) error {
Expand Down Expand Up @@ -207,6 +226,13 @@ func trySyncLocalToServer(
if isDryRun {
return nil
}
// SendLocalDataToServer returns nil without uploading when no token is
// configured. Advancing the cursor here would permanently skip those
// commands once the user authenticates.
if config.Token == "" && len(config.Endpoints) == 0 {
slog.Debug("no token configured; leaving commands buffered for a later sync")
return nil
}
return store.SetCursor(ctx, result.LatestRecordingTime)
}

Expand Down
37 changes: 37 additions & 0 deletions commands/track_cov_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,23 @@ func TestX3CommandTrack_PostSavesAndSyncs(t *testing.T) {
assert.GreaterOrEqual(t, atomic.LoadInt32(&calls), int32(1), "post phase should sync the batch over HTTP")
}

// TestX3CommandTrack_StaleSocketFallsBackToLocalSave covers a socket path that
// still exists after the daemon has died. Stat succeeds, Dial fails, and the
// command must be written locally instead of dropped.
func TestX3CommandTrack_StaleSocketFallsBackToLocalSave(t *testing.T) {
_, mc := x3SetupTrack(t)
stale := filepath.Join(t.TempDir(), "stale.sock")
require.NoError(t, os.WriteFile(stale, []byte("not a socket"), 0o644))
mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{SocketPath: stale}, nil)

app := &cli.App{Name: "t", Commands: []*cli.Command{TrackCommand}}
require.NoError(t, app.Run([]string{"t", "track", "--phase", "pre", "--shell", "bash", "--command", "echo stale", "--id", "3"}))

data, err := os.ReadFile(filepath.Join(model.GetCommandsStoragePath(), "pre.txt"))
require.NoError(t, err)
assert.Contains(t, string(data), "echo stale")
}

// --- trySyncLocalToServer direct -----------------------------------------------

// x3SeedPair writes a completed pre/post pair into the local file store under
Expand Down Expand Up @@ -217,6 +234,26 @@ func TestX3TrySyncLocalToServer_DryRunSkipsCursor(t *testing.T) {
assert.True(t, os.IsNotExist(statErr), "dry-run must not advance the cursor")
}

// TestX3TrySyncLocalToServer_NoTokenDoesNotAdvanceCursor covers an unauthenticated
// sync: SendLocalDataToServer returns nil without uploading, and the cursor must
// stay unset so the commands are still eligible after the user authenticates.
func TestX3TrySyncLocalToServer_NoTokenDoesNotAdvanceCursor(t *testing.T) {
otel.SetTracerProvider(noop.NewTracerProvider())
SKIP_LOGGER_SETTINGS = true
t.Setenv("HOME", t.TempDir())
x3SeedPair(t)

cfg := model.ShellTimeConfig{
SocketPath: filepath.Join(t.TempDir(), "absent.sock"),
FlushCount: 1,
}
require.NoError(t, trySyncLocalToServer(context.Background(), cfg, syncOptions{isForceSync: true}))

_, noCursor, err := model.NewFileStore().GetLastCursor(context.Background())
require.NoError(t, err)
assert.True(t, noCursor, "commands must stay buffered until a token is configured")
}

// TestX3TrySyncLocalToServer_SyncErrorPropagates covers the DoSyncData error
// branch: the server returns 500, so the send fails and the error surfaces.
func TestX3TrySyncLocalToServer_SyncErrorPropagates(t *testing.T) {
Expand Down
66 changes: 38 additions & 28 deletions daemon/handlers.sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"

"github.com/malamtime/cli/model"
Expand Down Expand Up @@ -80,40 +82,48 @@ func sendTrackArgsToServer(ctx context.Context, syncMsg model.PostTrackArgs) err
// configured intent.
return err
}
if len(ot.PublicKey) > 0 {
rs := model.NewRSAService()
as := model.NewAESGCMService()

k, _, err := as.GenerateKeys()

if err != nil {
slog.Error("Failed to generate aes-gcm key", slog.Any("err", err))
}
if strings.TrimSpace(ot.PublicKey) == "" {
err := fmt.Errorf("encryption is enabled but the open token public key is empty")
slog.Error(err.Error())
return err
}

encodedKey, _, err := rs.Encrypt(ot.PublicKey, k)
rs := model.NewRSAService()
as := model.NewAESGCMService()

if err != nil {
slog.Error("Failed to encrypt key", slog.Any("err", err))
}
k, _, err := as.GenerateKeys()
if err != nil {
slog.Error("Failed to generate aes-gcm key", slog.Any("err", err))
return err
}

buf, err := json.Marshal(payload)
encodedKey, _, err := rs.Encrypt(ot.PublicKey, k)
if err != nil {
slog.Error("Failed to encrypt key", slog.Any("err", err))
return err
}

if err != nil {
slog.Error("Failed to marshal payload", slog.Any("err", err))
return err
}
buf, err := json.Marshal(payload)
if err != nil {
slog.Error("Failed to marshal payload", slog.Any("err", err))
return err
}

encryptedData, nonce, err := as.Encrypt(string(k), buf)
if err != nil {
slog.Error("Failed to encrypt data", slog.Any("err", err))
return err
}
encryptedData, nonce, err := as.Encrypt(string(k), buf)
if err != nil {
slog.Error("Failed to encrypt data", slog.Any("err", err))
return err
}
if len(encodedKey) == 0 || len(encryptedData) == 0 || len(nonce) == 0 {
err := fmt.Errorf("encryption produced an empty key, ciphertext, or nonce")
slog.Error(err.Error())
return err
}

realPayload = model.PostTrackArgs{
Encrypted: base64.StdEncoding.EncodeToString(encryptedData),
AesKey: base64.StdEncoding.EncodeToString(encodedKey),
Nonce: base64.StdEncoding.EncodeToString(nonce),
}
realPayload = model.PostTrackArgs{
Encrypted: base64.StdEncoding.EncodeToString(encryptedData),
AesKey: base64.StdEncoding.EncodeToString(encodedKey),
Nonce: base64.StdEncoding.EncodeToString(nonce),
}
}

Expand Down
14 changes: 12 additions & 2 deletions daemon/handlers.track.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ func handlePubSubTrackPost(ctx context.Context, payload interface{}) error {
return nil
}

// Nothing would be uploaded. Ack the message (a nack re-runs SavePost and
// duplicates the command) and leave the cursor where it is so a later
// authenticated sync can still send this batch.
if cfg.Token == "" && len(cfg.Endpoints) == 0 {
slog.Debug("no token configured; leaving commands buffered for a later sync")
return nil
}

args := model.PostTrackArgs{
CursorID: result.LatestRecordingTime.UnixNano(),
Data: result.Data,
Expand All @@ -107,8 +115,10 @@ func handlePubSubTrackPost(ctx context.Context, payload interface{}) error {

if err := sendTrackArgsToServer(ctx, args); err != nil {
slog.Error("Failed to send tracking data from command store", slog.Any("err", err))
// Leave the data in the store; a later post will retry. Do not advance cursor.
return err
// Leave the data in the store; a later post will retry. Do not advance
// the cursor. Returning an error nacks the message, and the subscriber
// redelivers it — which appends this post command again.
return nil
}

if err := store.SetCursor(ctx, result.LatestRecordingTime); err != nil {
Expand Down
Loading
Loading