From 996672a6a7a076e27b416e737abf527525de88e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 17:33:34 -0400 Subject: [PATCH] fix(cli): protect Go install credentials --- AGENTS.md | 3 + README.md | 5 +- cmd/elydora/cli_secrets.go | 232 ++++++++++++++++++ cmd/elydora/cli_secrets_test.go | 212 ++++++++++++++++ cmd/elydora/main.go | 44 +++- cmd/elydora/plugins/droid.go | 2 +- cmd/elydora/plugins/hooktemplate.go | 98 +++----- cmd/elydora/plugins/qwen.go | 2 +- cmd/elydora/plugins/runtime_files.go | 145 +++++++++++ cmd/elydora/plugins/runtime_files_test.go | 202 +++++++++++++++ .../plugins/runtime_reader_template.go | 48 ++++ cmd/elydora/terminal_unix.go | 17 ++ cmd/elydora/terminal_windows.go | 116 +++++++++ cmd/elydora/terminal_windows_test.go | 70 ++++++ go.mod | 7 +- go.sum | 4 + 16 files changed, 1128 insertions(+), 79 deletions(-) create mode 100644 cmd/elydora/cli_secrets.go create mode 100644 cmd/elydora/cli_secrets_test.go create mode 100644 cmd/elydora/plugins/runtime_files.go create mode 100644 cmd/elydora/plugins/runtime_files_test.go create mode 100644 cmd/elydora/plugins/runtime_reader_template.go create mode 100644 cmd/elydora/terminal_unix.go create mode 100644 cmd/elydora/terminal_windows.go create mode 100644 cmd/elydora/terminal_windows_test.go diff --git a/AGENTS.md b/AGENTS.md index 7805870..6b5ca8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,9 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Use documented defaults only for genuinely optional configuration. - Avoid compatibility shims without a named public or user configuration contract. - Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous. +- Accept CLI install credentials through terminal-echo-disabled input or physical owner-only credential files. Keep credentials out of process arguments, reject legacy secret options with redacted errors, and require one UTF-8 line of at most 64 KiB. +- Commit runtime config, private key, and audit script through one rollback-capable transaction. Persist the signing key once with mode `0600`; generated runtimes validate physical identity, size, and Unix permissions before reading config or key material. +- Write the guard runtime through a flushed same-directory temporary file followed by atomic replacement. Surface permission, close, synchronization, commit, cleanup, and recovery failures. ## Verification diff --git a/README.md b/README.md index 18255a0..a6279c3 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,13 @@ elydora install \ --agent claudecode \ --org-id org-123 \ --agent-id agent-456 \ - --private-key \ + --private-key-file /secure/path/private.key \ + --token-file /secure/path/api.token \ --kid agent-456-key-v1 ``` +Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token without terminal echo. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. Installation commits `config.json`, `private.key`, and `hook.js` as one rollback-capable runtime transaction, and the generated scripts validate those protected files at execution time. + Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control. diff --git a/cmd/elydora/cli_secrets.go b/cmd/elydora/cli_secrets.go new file mode 100644 index 0000000..664f22e --- /dev/null +++ b/cmd/elydora/cli_secrets.go @@ -0,0 +1,232 @@ +package main + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" + "unicode/utf8" +) + +const maxSecretFileBytes = 64 * 1024 + +type installSecretSources struct { + privateKeyFile string + tokenFile string +} + +type installSecrets struct { + privateKey string + token string +} + +type secretTerminal interface { + Interactive() bool + ReadHidden(prompt string) (string, error) +} + +type standardSecretTerminal struct { + input *os.File + output *os.File +} + +func newStandardSecretTerminal() standardSecretTerminal { + return standardSecretTerminal{input: os.Stdin, output: os.Stderr} +} + +func (terminal standardSecretTerminal) Interactive() bool { + return isInteractiveTerminal(terminal.input) && + isInteractiveTerminal(terminal.output) +} + +func (terminal standardSecretTerminal) ReadHidden(prompt string) (string, error) { + if !terminal.Interactive() { + return "", errors.New("hidden input requires an interactive terminal") + } + if _, err := fmt.Fprint(terminal.output, prompt); err != nil { + return "", fmt.Errorf("write secret prompt: %w", err) + } + raw, readErr := readHiddenLine(terminal.input) + _, newlineErr := fmt.Fprintln(terminal.output) + if readErr != nil { + clear(raw) + return "", errors.Join( + fmt.Errorf("read hidden input: %w", readErr), + newlineErr, + ) + } + if newlineErr != nil { + clear(raw) + return "", fmt.Errorf("finish secret prompt: %w", newlineErr) + } + value := string(raw) + clear(raw) + return value, nil +} + +func parseSingleLineSecret(raw, label string, allowEmpty bool) (string, error) { + if len(raw) > maxSecretFileBytes { + return "", fmt.Errorf("%s exceeds %d bytes", label, maxSecretFileBytes) + } + if !utf8.ValidString(raw) { + return "", fmt.Errorf("%s must contain UTF-8 text", label) + } + value := raw + if strings.HasSuffix(value, "\r\n") { + value = strings.TrimSuffix(value, "\r\n") + } else if strings.HasSuffix(value, "\n") { + value = strings.TrimSuffix(value, "\n") + } + if !allowEmpty && value == "" { + return "", fmt.Errorf("%s is empty", label) + } + if strings.ContainsAny(value, "\r\n\x00") { + return "", fmt.Errorf("%s must contain exactly one line", label) + } + return value, nil +} + +func validateSecretFile(info os.FileInfo, path, label string) error { + if !info.Mode().IsRegular() { + return fmt.Errorf("%s file is not a regular file: %s", label, path) + } + if info.Size() > maxSecretFileBytes { + return fmt.Errorf("%s file exceeds %d bytes: %s", label, maxSecretFileBytes, path) + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 { + return fmt.Errorf("%s file must be accessible only by its owner: %s", label, path) + } + return nil +} + +func readSecretFile(path, label string) (string, error) { + return readSecretFileWithOpen(path, label, os.Open) +} + +func readSecretFileWithOpen( + path string, + label string, + openFile func(string) (*os.File, error), +) (value string, returnErr error) { + before, err := os.Lstat(path) + if err != nil { + return "", fmt.Errorf("inspect %s file at %s: %w", label, path, err) + } + if err := validateSecretFile(before, path, label); err != nil { + return "", err + } + if !os.SameFile(before, before) { + return "", fmt.Errorf("establish %s file identity at %s", label, path) + } + + file, err := openFile(path) + if err != nil { + return "", fmt.Errorf("open %s file at %s: %w", label, path, err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + returnErr = errors.Join( + returnErr, + fmt.Errorf("close %s file at %s: %w", label, path, closeErr), + ) + } + }() + + after, err := file.Stat() + if err != nil { + return "", fmt.Errorf("inspect open %s file at %s: %w", label, path, err) + } + if err := validateSecretFile(after, path, label); err != nil { + return "", err + } + if !os.SameFile(before, after) { + return "", fmt.Errorf("%s file changed while opening: %s", label, path) + } + + raw, err := io.ReadAll(io.LimitReader(file, maxSecretFileBytes+1)) + if err != nil { + return "", fmt.Errorf("read %s file at %s: %w", label, path, err) + } + defer clear(raw) + if len(raw) > maxSecretFileBytes { + return "", fmt.Errorf("%s file exceeds %d bytes: %s", label, maxSecretFileBytes, path) + } + return parseSingleLineSecret(string(raw), label, false) +} + +func resolveInstallSecrets( + sources installSecretSources, + terminal secretTerminal, +) (installSecrets, error) { + var privateKey string + var err error + if sources.privateKeyFile != "" { + privateKey, err = readSecretFile(sources.privateKeyFile, "private key") + } else if terminal.Interactive() { + privateKey, err = terminal.ReadHidden("Private key: ") + if err == nil { + privateKey, err = parseSingleLineSecret(privateKey, "private key", false) + } + } else { + return installSecrets{}, errors.New( + "private key input requires an interactive terminal or --private-key-file ", + ) + } + if err != nil { + return installSecrets{}, err + } + + var token string + if sources.tokenFile != "" { + token, err = readSecretFile(sources.tokenFile, "API token") + } else if terminal.Interactive() { + token, err = terminal.ReadHidden("API token (optional): ") + if err == nil { + token, err = parseSingleLineSecret(token, "API token", true) + } + } + if err != nil { + return installSecrets{}, err + } + return installSecrets{privateKey: privateKey, token: token}, nil +} + +func validatePrivateKey(privateKey string) error { + seed, err := base64.RawURLEncoding.DecodeString(privateKey) + if err != nil { + return fmt.Errorf("decode private key: %w", err) + } + defer clear(seed) + if len(seed) != ed25519.SeedSize { + return fmt.Errorf( + "private key seed must be %d bytes, got %d", + ed25519.SeedSize, + len(seed), + ) + } + return nil +} + +func rejectLegacySecretArguments(arguments []string) error { + legacyOptions := map[string]string{ + "--private-key": "--private-key-file", + "-private-key": "--private-key-file", + "--token": "--token-file", + "-token": "--token-file", + } + for _, argument := range arguments { + option := strings.SplitN(argument, "=", 2)[0] + if replacement, exists := legacyOptions[option]; exists { + return fmt.Errorf( + "%s exposes credentials in process arguments; use %s or hidden terminal input", + option, + replacement, + ) + } + } + return nil +} diff --git a/cmd/elydora/cli_secrets_test.go b/cmd/elydora/cli_secrets_test.go new file mode 100644 index 0000000..457bdf9 --- /dev/null +++ b/cmd/elydora/cli_secrets_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const testAPIToken = "ely_test_token" + +type fakeSecretTerminal struct { + interactive bool + answers []string + prompts []string +} + +func (terminal *fakeSecretTerminal) Interactive() bool { + return terminal.interactive +} + +func (terminal *fakeSecretTerminal) ReadHidden(prompt string) (string, error) { + terminal.prompts = append(terminal.prompts, prompt) + if len(terminal.answers) == 0 { + return "", os.ErrInvalid + } + answer := terminal.answers[0] + terminal.answers = terminal.answers[1:] + return answer, nil +} + +func testPrivateKey() string { + return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)) +} + +func writeSecretFile(t *testing.T, path, value string) { + t.Helper() + if err := os.WriteFile(path, []byte(value), 0600); err != nil { + t.Fatalf("write secret file: %v", err) + } + if runtime.GOOS != "windows" { + if err := os.Chmod(path, 0600); err != nil { + t.Fatalf("restrict secret file: %v", err) + } + } +} + +func TestResolveInstallSecretsUsesHiddenPrompts(t *testing.T) { + terminal := &fakeSecretTerminal{ + interactive: true, + answers: []string{testPrivateKey(), testAPIToken}, + } + secrets, err := resolveInstallSecrets(installSecretSources{}, terminal) + if err != nil { + t.Fatalf("resolve install secrets: %v", err) + } + if secrets.privateKey != testPrivateKey() || secrets.token != testAPIToken { + t.Fatalf("resolved secrets have unexpected values") + } + wantPrompts := []string{"Private key: ", "API token (optional): "} + if strings.Join(terminal.prompts, "|") != strings.Join(wantPrompts, "|") { + t.Fatalf("prompts = %q, want %q", terminal.prompts, wantPrompts) + } +} + +func TestResolveInstallSecretsRequiresFileWhenNoninteractive(t *testing.T) { + terminal := &fakeSecretTerminal{interactive: false} + _, err := resolveInstallSecrets(installSecretSources{}, terminal) + if err == nil || !strings.Contains(err.Error(), "--private-key-file ") { + t.Fatalf("resolve install secrets error = %v", err) + } + if len(terminal.prompts) != 0 { + t.Fatalf("unexpected prompts: %q", terminal.prompts) + } +} + +func TestResolveInstallSecretsReadsCredentialFiles(t *testing.T) { + directory := t.TempDir() + privateKeyPath := filepath.Join(directory, "private.key") + tokenPath := filepath.Join(directory, "token") + writeSecretFile(t, privateKeyPath, testPrivateKey()+"\r\n") + writeSecretFile(t, tokenPath, testAPIToken+"\n") + + secrets, err := resolveInstallSecrets(installSecretSources{ + privateKeyFile: privateKeyPath, + tokenFile: tokenPath, + }, &fakeSecretTerminal{}) + if err != nil { + t.Fatalf("resolve install secrets: %v", err) + } + if secrets.privateKey != testPrivateKey() || secrets.token != testAPIToken { + t.Fatalf("resolved secrets have unexpected values") + } +} + +func TestReadSecretFileRejectsInvalidContent(t *testing.T) { + tests := []struct { + name string + content string + pattern string + }{ + {name: "empty", content: "", pattern: "is empty"}, + {name: "multiple lines", content: testPrivateKey() + "\nsecond\n", pattern: "one line"}, + {name: "nul", content: "key\x00value", pattern: "one line"}, + {name: "invalid UTF-8", content: string([]byte{0xff}), pattern: "UTF-8"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "private.key") + writeSecretFile(t, path, test.content) + _, err := readSecretFile(path, "private key") + if err == nil || !strings.Contains(err.Error(), test.pattern) { + t.Fatalf("read secret file error = %v", err) + } + }) + } +} + +func TestReadSecretFileRejectsOversizedContent(t *testing.T) { + path := filepath.Join(t.TempDir(), "private.key") + writeSecretFile(t, path, strings.Repeat("a", maxSecretFileBytes+1)) + _, err := readSecretFile(path, "private key") + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("read secret file error = %v", err) + } +} + +func TestReadSecretFileRejectsSymbolicLink(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "target.key") + link := filepath.Join(directory, "linked.key") + writeSecretFile(t, target, testPrivateKey()) + if err := os.Symlink(target, link); err != nil { + t.Skipf("file symbolic links unavailable: %v", err) + } + _, err := readSecretFile(link, "private key") + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("read secret file error = %v", err) + } +} + +func TestReadSecretFileRequiresOwnerOnlyPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission contract") + } + path := filepath.Join(t.TempDir(), "private.key") + writeSecretFile(t, path, testPrivateKey()) + if err := os.Chmod(path, 0640); err != nil { + t.Fatalf("widen secret file permissions: %v", err) + } + _, err := readSecretFile(path, "private key") + if err == nil || !strings.Contains(err.Error(), "only by its owner") { + t.Fatalf("read secret file error = %v", err) + } +} + +func TestReadSecretFileDetectsReplacementWhileOpening(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "private.key") + replacement := filepath.Join(directory, "replacement.key") + writeSecretFile(t, path, testPrivateKey()) + writeSecretFile(t, replacement, testPrivateKey()) + + _, err := readSecretFileWithOpen(path, "private key", func(name string) (*os.File, error) { + if removeErr := os.Remove(path); removeErr != nil { + return nil, removeErr + } + if renameErr := os.Rename(replacement, path); renameErr != nil { + return nil, renameErr + } + return os.Open(name) + }) + if err == nil || !strings.Contains(err.Error(), "changed while opening") { + t.Fatalf("read secret file error = %v", err) + } +} + +func TestValidatePrivateKeyRejectsInvalidSeeds(t *testing.T) { + tests := []string{"invalid!", base64.RawURLEncoding.EncodeToString([]byte("short"))} + for _, privateKey := range tests { + if err := validatePrivateKey(privateKey); err == nil { + t.Fatalf("validatePrivateKey(%q) succeeded", privateKey) + } + } + if err := validatePrivateKey(testPrivateKey()); err != nil { + t.Fatalf("validate private key: %v", err) + } +} + +func TestRejectLegacySecretArgumentsRedactsValues(t *testing.T) { + tests := [][]string{ + {"--private-key", "must-not-appear"}, + {"--private-key=must-not-appear"}, + {"--token", "must-not-appear"}, + {"--token=must-not-appear"}, + } + for _, arguments := range tests { + err := rejectLegacySecretArguments(arguments) + if err == nil { + t.Fatalf("rejectLegacySecretArguments(%q) succeeded", arguments) + } + if strings.Contains(err.Error(), "must-not-appear") { + t.Fatalf("legacy secret value leaked in error: %v", err) + } + if !strings.Contains(err.Error(), "-file") { + t.Fatalf("replacement option missing from error: %v", err) + } + } +} diff --git a/cmd/elydora/main.go b/cmd/elydora/main.go index 55e8ff5..8e4af9d 100644 --- a/cmd/elydora/main.go +++ b/cmd/elydora/main.go @@ -62,19 +62,24 @@ Run "elydora -h" for details on each command. // --------------------------------------------------------------------------- func cmdInstall(args []string) { + if err := rejectLegacySecretArguments(args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fs := flag.NewFlagSet("install", flag.ExitOnError) agent := fs.String("agent", "", "Agent name (required). Use 'elydora agents' to list.") orgID := fs.String("org-id", "", "Organization ID (required)") agentID := fs.String("agent-id", "", "Agent ID (required)") - privateKey := fs.String("private-key", "", "Base64url-encoded Ed25519 private key seed (required)") + privateKeyFile := fs.String("private-key-file", "", "Owner-only file containing the Ed25519 private key seed") kid := fs.String("kid", "", "Key ID (defaults to -key-1)") - token := fs.String("token", "", "API token for authenticated requests (optional)") + tokenFile := fs.String("token-file", "", "Owner-only file containing the optional API token") baseURL := fs.String("base-url", "https://api.elydora.com", "Elydora API base URL") fs.Parse(args) - if *agent == "" || *orgID == "" || *agentID == "" || *privateKey == "" { - fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, --agent-id, and --private-key are required.") + if *agent == "" || *orgID == "" || *agentID == "" { + fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, and --agent-id are required.") fmt.Fprintln(os.Stderr) fs.Usage() os.Exit(1) @@ -89,6 +94,26 @@ func cmdInstall(args []string) { fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent) os.Exit(1) } + if _, err := plugins.ResolveAgentRuntimeDirectory(*agentID); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + secrets, err := resolveInstallSecrets( + installSecretSources{ + privateKeyFile: *privateKeyFile, + tokenFile: *tokenFile, + }, + newStandardSecretTerminal(), + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if err := validatePrivateKey(secrets.privateKey); err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid private key: %v\n", err) + os.Exit(1) + } // Generate guard script (PreToolUse — freeze enforcement) agentDirectory, err := plugins.PrepareAgentRuntimeDirectory(*agentID) @@ -98,7 +123,12 @@ func cmdInstall(args []string) { } guardScriptPath := filepath.Join(agentDirectory, "guard.js") guardScript := plugins.GenerateGuardScript(*agent, *agentID) - if err := os.WriteFile(guardScriptPath, []byte(guardScript), 0755); err != nil { + if err := plugins.WriteRuntimeFileAtomic( + guardScriptPath, + "Elydora guard runtime", + []byte(guardScript), + 0700, + ); err != nil { fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err) os.Exit(1) } @@ -108,9 +138,9 @@ func cmdInstall(args []string) { AgentName: *agent, OrgID: *orgID, AgentID: *agentID, - PrivateKey: *privateKey, + PrivateKey: secrets.privateKey, KID: *kid, - Token: *token, + Token: secrets.token, BaseURL: *baseURL, GuardScriptPath: guardScriptPath, } diff --git a/cmd/elydora/plugins/droid.go b/cmd/elydora/plugins/droid.go index 8bf8172..9dc3984 100644 --- a/cmd/elydora/plugins/droid.go +++ b/cmd/elydora/plugins/droid.go @@ -194,7 +194,7 @@ func prepareDroidInstallationChanges( mode os.FileMode }{ {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600}, + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600}, {auditPath, "Elydora audit runtime", []byte(buildHookScript(droidAgentKey, config.AgentID)), 0700}, } changes := make([]*fileChange, 0, len(runtimeChanges)+len(rendered)) diff --git a/cmd/elydora/plugins/hooktemplate.go b/cmd/elydora/plugins/hooktemplate.go index f89303c..5a57035 100644 --- a/cmd/elydora/plugins/hooktemplate.go +++ b/cmd/elydora/plugins/hooktemplate.go @@ -2,71 +2,8 @@ package plugins import ( "fmt" - "os" - "path/filepath" ) -// GenerateHookScript writes a self-contained Node.js hook script to disk. -// The script reads config/key from ~/.elydora// at runtime, persists -// chain state, and performs fire-and-forget EOR submission with inline Ed25519 crypto. -// It always exits 0 so it never blocks the host agent. -func GenerateHookScript(destPath string, config InstallConfig) error { - dir := filepath.Dir(destPath) - if err := EnsurePrivateDirectory(dir); err != nil { - return fmt.Errorf("create hook script directory: %w", err) - } - - // Write the agent config and key files for runtime reading - if err := writeAgentConfig(config); err != nil { - return fmt.Errorf("write agent config: %w", err) - } - - script := buildHookScript(config.AgentName, config.AgentID) - if err := os.WriteFile(destPath, []byte(script), 0755); err != nil { - return fmt.Errorf("write hook script: %w", err) - } - return nil -} - -// writeAgentConfig persists the agent configuration and private key to -// ~/.elydora//config.json and ~/.elydora//private.key so that -// the generated hook script can read them at runtime without embedding secrets. -func writeAgentConfig(config InstallConfig) error { - agentDir, err := PrepareAgentRuntimeDirectory(config.AgentID) - if err != nil { - return err - } - - baseURL := config.BaseURL - if baseURL == "" { - baseURL = "https://api.elydora.com" - } - - configJSON := fmt.Sprintf(`{ - "org_id": %q, - "agent_id": %q, - "kid": %q, - "base_url": %q, - "token": %q, - "agent_name": %q -} -`, config.OrgID, config.AgentID, config.KID, baseURL, config.Token, config.AgentName) - - configPath := filepath.Join(agentDir, "config.json") - // Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows. - if err := os.WriteFile(configPath, []byte(configJSON), 0600); err != nil { - return fmt.Errorf("write agent config: %w", err) - } - - keyPath := filepath.Join(agentDir, "private.key") - // Note: 0600 permissions are a no-op on Windows; Go's os.Chmod only handles the read-only bit on Windows. - if err := os.WriteFile(keyPath, []byte(config.PrivateKey+"\n"), 0600); err != nil { - return fmt.Errorf("write agent key: %w", err) - } - - return nil -} - // GenerateGuardScript returns a self-contained Node.js guard script that checks // whether the agent is frozen and blocks tool execution if so. // It caches the agent status locally (60s TTL) to avoid hitting the API on every @@ -91,6 +28,8 @@ const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json'); const CACHE_TTL_MS = 60000; // 60 seconds +%s + // Consume stdin so the parent process doesn't block on a full pipe process.stdin.resume(); process.stdin.on('data', () => {}); @@ -99,7 +38,11 @@ async function main() { // Read agent config let config; try { - config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + config = JSON.parse(readProtectedFile( + CONFIG_PATH, + 'Agent config', + MAX_PROTECTED_CONFIG_BYTES, + ).toString('utf-8')); } catch { return; // Can't read config — fail-open (allow) } @@ -166,7 +109,7 @@ async function main() { } main(); -`, agentName, agentName, agentId) +`, agentName, agentName, agentId, protectedRuntimeFileReader) } func buildHookScript(agentName string, agentId string) string { @@ -191,6 +134,21 @@ const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key'); const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json'); const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log'); +%s + +function parsePrivateKey(raw) { + let value = raw.toString('utf-8'); + if (value.endsWith('\r\n')) { + value = value.slice(0, -2); + } else if (value.endsWith('\n')) { + value = value.slice(0, -1); + } + if (!value || value.includes('\r') || value.includes('\n') || value.includes('\0')) { + throw new Error('Private key file must contain exactly one line'); + } + return value; +} + // --------------------------------------------------------------------------- // Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts) // --------------------------------------------------------------------------- @@ -369,8 +327,12 @@ async function main() { let config; let privateKey; try { - config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); - privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim(); + config = JSON.parse(readProtectedFile( + CONFIG_PATH, + 'Agent config', + MAX_PROTECTED_CONFIG_BYTES, + ).toString('utf-8')); + privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key')); } catch (err) { logError(new Error('Failed to read agent config/key: ' + err.message)); return; @@ -471,5 +433,5 @@ async function main() { } main(); -`, agentName, agentName, agentId) +`, agentName, agentName, agentId, protectedRuntimeFileReader) } diff --git a/cmd/elydora/plugins/qwen.go b/cmd/elydora/plugins/qwen.go index ab81eb8..5bd123b 100644 --- a/cmd/elydora/plugins/qwen.go +++ b/cmd/elydora/plugins/qwen.go @@ -153,7 +153,7 @@ func prepareQwenInstallationChanges( mode os.FileMode }{ {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey + "\n"), 0600}, + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600}, {auditPath, "Elydora audit runtime", []byte(buildHookScript(qwenAgentKey, config.AgentID)), 0700}, } changes := make([]*fileChange, 0, len(items)+1) diff --git a/cmd/elydora/plugins/runtime_files.go b/cmd/elydora/plugins/runtime_files.go new file mode 100644 index 0000000..57eddf5 --- /dev/null +++ b/cmd/elydora/plugins/runtime_files.go @@ -0,0 +1,145 @@ +package plugins + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + maxProtectedSecretBytes = 64 * 1024 + maxRuntimeConfigBytes = 512 * 1024 +) + +type agentRuntimeConfig struct { + OrgID string `json:"org_id"` + AgentID string `json:"agent_id"` + KID string `json:"kid"` + BaseURL string `json:"base_url"` + Token string `json:"token"` + AgentName string `json:"agent_name"` +} + +// WriteRuntimeFileAtomic replaces one managed runtime file through a flushed, +// same-directory temporary file. +func WriteRuntimeFileAtomic( + path string, + label string, + content []byte, + mode os.FileMode, +) error { + if err := validateRuntimeFileTarget(path, label); err != nil { + return err + } + change, err := prepareFileChange(path, label, content, mode) + if err != nil { + return err + } + return writeChanges([]*fileChange{change}, "write "+label, os.Rename) +} + +// GenerateHookScript atomically commits the agent config, private key, and +// self-contained Node.js audit runtime. +func GenerateHookScript(destPath string, config InstallConfig) error { + return generateHookScriptWithRename(destPath, config, os.Rename) +} + +func generateHookScriptWithRename( + destPath string, + config InstallConfig, + rename renameFunc, +) error { + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return err + } + + baseURL := config.BaseURL + if baseURL == "" { + baseURL = "https://api.elydora.com" + } + encodedConfig, err := json.MarshalIndent(agentRuntimeConfig{ + OrgID: config.OrgID, + AgentID: config.AgentID, + KID: config.KID, + BaseURL: baseURL, + Token: config.Token, + AgentName: config.AgentName, + }, "", " ") + if err != nil { + return fmt.Errorf("encode Elydora runtime config: %w", err) + } + encodedConfig = append(encodedConfig, '\n') + if len(encodedConfig) > maxRuntimeConfigBytes { + return fmt.Errorf( + "Elydora runtime config exceeds %d bytes after JSON encoding", + maxRuntimeConfigBytes, + ) + } + + items := []struct { + path string + label string + content []byte + mode os.FileMode + }{ + { + path: filepath.Join(agentDirectory, "config.json"), label: "Elydora runtime config", + content: encodedConfig, mode: 0600, + }, + { + path: filepath.Join(agentDirectory, "private.key"), label: "Elydora private key", + content: []byte(config.PrivateKey), mode: 0600, + }, + { + path: destPath, label: "Elydora audit runtime", + content: []byte(buildHookScript(config.AgentName, config.AgentID)), mode: 0700, + }, + } + + for _, item := range items { + if err := validateRuntimeFileTarget(item.path, item.label); err != nil { + return err + } + } + if _, err := PrepareAgentRuntimeDirectory(config.AgentID); err != nil { + return err + } + if err := EnsurePrivateDirectory(filepath.Dir(destPath)); err != nil { + return fmt.Errorf("create hook script directory: %w", err) + } + + changes := make([]*fileChange, 0, len(items)) + for _, item := range items { + change, prepareErr := prepareFileChange( + item.path, + item.label, + item.content, + item.mode, + ) + if prepareErr != nil { + return prepareErr + } + changes = append(changes, change) + } + if err := writeChanges(changes, "write Elydora agent runtime", rename); err != nil { + return fmt.Errorf("commit Elydora agent runtime: %w", err) + } + return nil +} + +func validateRuntimeFileTarget(path, label string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect %s at %s: %w", label, path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("%s path is not a physical file: %s", label, path) + } + return nil +} diff --git a/cmd/elydora/plugins/runtime_files_test.go b/cmd/elydora/plugins/runtime_files_test.go new file mode 100644 index 0000000..7267598 --- /dev/null +++ b/cmd/elydora/plugins/runtime_files_test.go @@ -0,0 +1,202 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func setRuntimeFilesTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return home +} + +func runtimeFilesTestConfig() InstallConfig { + return InstallConfig{ + AgentName: "opencode", + OrgID: "org-1", + AgentID: "agent-1", + PrivateKey: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc", + KID: "key-1", + Token: "ely_test_token", + BaseURL: "https://api.elydora.test", + } +} + +func TestGenerateHookScriptCommitsProtectedRuntimeFiles(t *testing.T) { + home := setRuntimeFilesTestHome(t) + config := runtimeFilesTestConfig() + agentDirectory := filepath.Join(home, ".elydora", config.AgentID) + hookPath := filepath.Join(agentDirectory, "hook.js") + + if err := GenerateHookScript(hookPath, config); err != nil { + t.Fatalf("generate hook script: %v", err) + } + + keyPath := filepath.Join(agentDirectory, "private.key") + key, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read private key: %v", err) + } + if string(key) != config.PrivateKey { + t.Fatalf("private key content changed") + } + hook, err := os.ReadFile(hookPath) + if err != nil { + t.Fatalf("read hook runtime: %v", err) + } + if strings.Contains(string(hook), config.PrivateKey) { + t.Fatalf("hook runtime embeds the private key") + } + if !strings.Contains(string(hook), "readProtectedFile") { + t.Fatalf("hook runtime is missing protected file validation") + } + if runtime.GOOS != "windows" { + assertRuntimeFileMode(t, filepath.Join(agentDirectory, "config.json"), 0600) + assertRuntimeFileMode(t, keyPath, 0600) + assertRuntimeFileMode(t, hookPath, 0700) + } + assertNoRuntimeStagingFiles(t, agentDirectory) +} + +func TestGenerateHookScriptSupportsMaxEscapedCredentialInput(t *testing.T) { + home := setRuntimeFilesTestHome(t) + config := runtimeFilesTestConfig() + config.Token = strings.Repeat("\x01", 64*1024) + agentDirectory := filepath.Join(home, ".elydora", config.AgentID) + hookPath := filepath.Join(agentDirectory, "hook.js") + + if err := GenerateHookScript(hookPath, config); err != nil { + t.Fatalf("generate hook script with maximum escaped token: %v", err) + } + info, err := os.Stat(filepath.Join(agentDirectory, "config.json")) + if err != nil { + t.Fatalf("stat runtime config: %v", err) + } + if info.Size() <= 64*1024 || info.Size() > maxRuntimeConfigBytes { + t.Fatalf("encoded runtime config size = %d", info.Size()) + } + hook, err := os.ReadFile(hookPath) + if err != nil { + t.Fatalf("read hook runtime: %v", err) + } + if !strings.Contains(string(hook), "MAX_PROTECTED_CONFIG_BYTES") { + t.Fatalf("hook runtime is missing the config size contract") + } +} + +func TestGenerateHookScriptRejectsOversizedRuntimeConfig(t *testing.T) { + home := setRuntimeFilesTestHome(t) + config := runtimeFilesTestConfig() + config.Token = strings.Repeat("\x01", maxRuntimeConfigBytes) + agentDirectory := filepath.Join(home, ".elydora", config.AgentID) + + err := GenerateHookScript(filepath.Join(agentDirectory, "hook.js"), config) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("generate oversized runtime config error = %v", err) + } + if _, statErr := os.Lstat(agentDirectory); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("oversized config created runtime state: %v", statErr) + } +} + +func TestGenerateHookScriptRollsBackRuntimeTransaction(t *testing.T) { + home := setRuntimeFilesTestHome(t) + config := runtimeFilesTestConfig() + agentDirectory := filepath.Join(home, ".elydora", config.AgentID) + if err := os.MkdirAll(agentDirectory, 0700); err != nil { + t.Fatalf("create runtime directory: %v", err) + } + paths := []string{ + filepath.Join(agentDirectory, "config.json"), + filepath.Join(agentDirectory, "private.key"), + filepath.Join(agentDirectory, "hook.js"), + } + for _, path := range paths { + if err := os.WriteFile(path, []byte("original:"+filepath.Base(path)), 0600); err != nil { + t.Fatalf("write original runtime file: %v", err) + } + } + + commits := 0 + rename := func(source, destination string) error { + if strings.HasSuffix(source, ".tmp") { + commits++ + if commits == 2 { + return errors.New("injected runtime commit failure") + } + } + return os.Rename(source, destination) + } + err := generateHookScriptWithRename(paths[2], config, rename) + if err == nil || !strings.Contains(err.Error(), "injected runtime commit failure") { + t.Fatalf("generate hook script error = %v", err) + } + for _, path := range paths { + raw, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read restored runtime file: %v", readErr) + } + want := "original:" + filepath.Base(path) + if string(raw) != want { + t.Fatalf("restored %s = %q, want %q", path, raw, want) + } + } + assertNoRuntimeStagingFiles(t, agentDirectory) +} + +func TestGenerateHookScriptRejectsLinkedSecretTarget(t *testing.T) { + home := setRuntimeFilesTestHome(t) + config := runtimeFilesTestConfig() + agentDirectory := filepath.Join(home, ".elydora", config.AgentID) + if err := os.MkdirAll(agentDirectory, 0700); err != nil { + t.Fatalf("create runtime directory: %v", err) + } + target := filepath.Join(t.TempDir(), "private.key") + if err := os.WriteFile(target, []byte("external"), 0600); err != nil { + t.Fatalf("write linked target: %v", err) + } + if err := os.Symlink(target, filepath.Join(agentDirectory, "private.key")); err != nil { + t.Skipf("file symbolic links unavailable: %v", err) + } + + err := GenerateHookScript(filepath.Join(agentDirectory, "hook.js"), config) + if err == nil || !strings.Contains(err.Error(), "physical file") { + t.Fatalf("generate hook script error = %v", err) + } + raw, readErr := os.ReadFile(target) + if readErr != nil || string(raw) != "external" { + t.Fatalf("linked target changed: %q, %v", raw, readErr) + } +} + +func assertRuntimeFileMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat runtime file: %v", err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("mode for %s = %04o, want %04o", path, got, want) + } +} + +func assertNoRuntimeStagingFiles(t *testing.T, directory string) { + t.Helper() + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatalf("read runtime directory: %v", err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".") && + (strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback")) { + t.Fatalf("staging file remains: %s", entry.Name()) + } + } +} diff --git a/cmd/elydora/plugins/runtime_reader_template.go b/cmd/elydora/plugins/runtime_reader_template.go new file mode 100644 index 0000000..862bd9d --- /dev/null +++ b/cmd/elydora/plugins/runtime_reader_template.go @@ -0,0 +1,48 @@ +package plugins + +import "fmt" + +var protectedRuntimeFileReader = fmt.Sprintf(`const MAX_PROTECTED_SECRET_BYTES = %d; +const MAX_PROTECTED_CONFIG_BYTES = %d; + +function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) { + const before = fs.lstatSync(filePath); + if (!before.isFile() || before.isSymbolicLink()) { + throw new Error(label + ' path is not a physical file: ' + filePath); + } + if (before.size > maxBytes) { + throw new Error(label + ' exceeds the size limit: ' + filePath); + } + if (process.platform !== 'win32' && (before.mode & 0o077) !== 0) { + throw new Error(label + ' permissions are too broad: ' + filePath); + } + + let flags = fs.constants.O_RDONLY; + if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') { + flags |= fs.constants.O_NOFOLLOW; + } + const descriptor = fs.openSync(filePath, flags); + try { + const after = fs.fstatSync(descriptor); + if (!after.isFile()) { + throw new Error(label + ' path is not a physical file: ' + filePath); + } + if (after.size > maxBytes) { + throw new Error(label + ' exceeds the size limit: ' + filePath); + } + if (process.platform !== 'win32' && (after.mode & 0o077) !== 0) { + throw new Error(label + ' permissions are too broad: ' + filePath); + } + if (before.dev !== after.dev || before.ino !== after.ino) { + throw new Error(label + ' changed while opening: ' + filePath); + } + const raw = fs.readFileSync(descriptor); + if (raw.length > maxBytes) { + throw new Error(label + ' exceeds the size limit: ' + filePath); + } + return raw; + } finally { + fs.closeSync(descriptor); + } +} +`, maxProtectedSecretBytes, maxRuntimeConfigBytes) diff --git a/cmd/elydora/terminal_unix.go b/cmd/elydora/terminal_unix.go new file mode 100644 index 0000000..f774242 --- /dev/null +++ b/cmd/elydora/terminal_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package main + +import ( + "os" + + "golang.org/x/term" +) + +func isInteractiveTerminal(file *os.File) bool { + return term.IsTerminal(int(file.Fd())) +} + +func readHiddenLine(file *os.File) ([]byte, error) { + return term.ReadPassword(int(file.Fd())) +} diff --git a/cmd/elydora/terminal_windows.go b/cmd/elydora/terminal_windows.go new file mode 100644 index 0000000..44fe4ae --- /dev/null +++ b/cmd/elydora/terminal_windows.go @@ -0,0 +1,116 @@ +//go:build windows + +package main + +import ( + "errors" + "fmt" + "io" + "os" + "syscall" +) + +const ( + windowsEnableProcessedInput = 0x0001 + windowsEnableLineInput = 0x0002 + windowsEnableEchoInput = 0x0004 + windowsEnableProcessedOutput = 0x0001 +) + +var setConsoleMode = syscall.NewLazyDLL("kernel32.dll").NewProc("SetConsoleMode") + +func isInteractiveTerminal(file *os.File) bool { + var mode uint32 + return syscall.GetConsoleMode(syscall.Handle(file.Fd()), &mode) == nil +} + +func readHiddenLine(file *os.File) (raw []byte, returnErr error) { + handle := syscall.Handle(file.Fd()) + var originalMode uint32 + if err := syscall.GetConsoleMode(handle, &originalMode); err != nil { + return nil, fmt.Errorf("read console mode: %w", err) + } + hiddenMode := originalMode &^ (windowsEnableEchoInput | windowsEnableLineInput) + hiddenMode |= windowsEnableProcessedInput | windowsEnableProcessedOutput + if err := updateConsoleMode(handle, hiddenMode); err != nil { + return nil, fmt.Errorf("disable terminal echo: %w", err) + } + defer func() { + if err := updateConsoleMode(handle, originalMode); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("restore console mode: %w", err)) + } + }() + + process, err := syscall.GetCurrentProcess() + if err != nil { + return nil, fmt.Errorf("get current process: %w", err) + } + var duplicate syscall.Handle + if err := syscall.DuplicateHandle( + process, + handle, + process, + &duplicate, + 0, + false, + syscall.DUPLICATE_SAME_ACCESS, + ); err != nil { + return nil, fmt.Errorf("duplicate terminal handle: %w", err) + } + input := os.NewFile(uintptr(duplicate), file.Name()) + if input == nil { + _ = syscall.CloseHandle(duplicate) + return nil, errors.New("create terminal reader from duplicate handle") + } + defer func() { + if err := input.Close(); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("close terminal reader: %w", err)) + } + }() + + return readWindowsPasswordLine(input) +} + +func updateConsoleMode(handle syscall.Handle, mode uint32) error { + result, _, callErr := setConsoleMode.Call(uintptr(handle), uintptr(mode)) + if result != 0 { + return nil + } + if callErr != syscall.Errno(0) { + return callErr + } + return syscall.EINVAL +} + +func readWindowsPasswordLine(reader io.Reader) ([]byte, error) { + var value []byte + var buffer [1]byte + for { + read, err := reader.Read(buffer[:]) + if read > 0 { + switch buffer[0] { + case '\b': + if len(value) > 0 { + value = value[:len(value)-1] + } + case '\r': + return value, nil + case '\n': + default: + value = append(value, buffer[0]) + if len(value) > maxSecretFileBytes { + clear(value) + return nil, fmt.Errorf("hidden input exceeds %d bytes", maxSecretFileBytes) + } + } + continue + } + if err != nil { + if errors.Is(err, io.EOF) && len(value) > 0 { + return value, nil + } + clear(value) + return nil, err + } + } +} diff --git a/cmd/elydora/terminal_windows_test.go b/cmd/elydora/terminal_windows_test.go new file mode 100644 index 0000000..1c9c5c0 --- /dev/null +++ b/cmd/elydora/terminal_windows_test.go @@ -0,0 +1,70 @@ +//go:build windows + +package main + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" +) + +func TestReadWindowsPasswordLineHandlesConsoleInput(t *testing.T) { + raw, err := readWindowsPasswordLine(strings.NewReader("secx\bret\n\rignored")) + if err != nil { + t.Fatalf("read Windows password line: %v", err) + } + if string(raw) != "secret" { + t.Fatalf("password = %q, want secret", raw) + } +} + +func TestReadWindowsPasswordLineReturnsFinalLineAtEOF(t *testing.T) { + raw, err := readWindowsPasswordLine(strings.NewReader("secret")) + if err != nil { + t.Fatalf("read Windows password line: %v", err) + } + if string(raw) != "secret" { + t.Fatalf("password = %q, want secret", raw) + } +} + +func TestReadWindowsPasswordLineRejectsOversizedInput(t *testing.T) { + raw, err := readWindowsPasswordLine(bytes.NewReader( + bytes.Repeat([]byte{'x'}, maxSecretFileBytes+1), + )) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("read Windows password line error = %v", err) + } + if raw != nil { + t.Fatalf("oversized password remained in memory") + } +} + +func TestReadWindowsPasswordLineReturnsReaderFailure(t *testing.T) { + expected := errors.New("injected read failure") + raw, err := readWindowsPasswordLine(&partialErrorReader{err: expected}) + if !errors.Is(err, expected) { + t.Fatalf("read Windows password line error = %v", err) + } + if raw != nil { + t.Fatalf("unexpected password bytes: %q", raw) + } +} + +type partialErrorReader struct { + err error + returned bool +} + +func (reader *partialErrorReader) Read(buffer []byte) (int, error) { + if !reader.returned { + reader.returned = true + buffer[0] = 's' + return 1, nil + } + return 0, reader.err +} + +var _ io.Reader = &partialErrorReader{} diff --git a/go.mod b/go.mod index 15840ca..7d91312 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,9 @@ go 1.21.0 require github.com/pelletier/go-toml/v2 v2.4.3 -require github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a +require ( + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a + golang.org/x/term v0.29.0 +) + +require golang.org/x/sys v0.30.0 // indirect diff --git a/go.sum b/go.sum index f3931d0..9de05aa 100644 --- a/go.sum +++ b/go.sum @@ -4,3 +4,7 @@ github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdD github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=