fix(go): protect CLI install credentials
This commit is contained in:
1 parent
4f9108099c
commit
5384565b4f
16 files changed
+1126
-79
No files matched your search
@@ -59,6 +59,7 @@ This repository publishes the open-source server, console, integration catalog,
|
|||||||
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files.
|
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files.
|
||||||
- Keep Console instruction generation limited to agent identity and public configuration. Adapter commands use hidden CLI prompts; direct SDK examples read named runtime environment variables. Expose credentials through explicit copy controls only.
|
- Keep Console instruction generation limited to agent identity and public configuration. Adapter commands use hidden CLI prompts; direct SDK examples read named runtime environment variables. Expose credentials through explicit copy controls only.
|
||||||
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
|
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
|
||||||
|
- Go CLI install credentials come from terminal-echo-disabled input or physical owner-only files containing one UTF-8 line of at most 64 KiB. Runtime config, private key, and audit script form one rollback-capable transaction, and generated runtimes validate file identity, size, and Unix permissions before reads.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
+4
-1
@@ -69,10 +69,13 @@ elydora install \
|
|||||||
--agent claudecode \
|
--agent claudecode \
|
||||||
--org-id org-123 \
|
--org-id org-123 \
|
||||||
--agent-id agent-456 \
|
--agent-id agent-456 \
|
||||||
--private-key <key> \
|
--private-key-file /secure/path/private.key \
|
||||||
|
--token-file /secure/path/api.token \
|
||||||
--kid agent-456-key-v1
|
--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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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 <path>",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 <path>") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,19 +62,24 @@ Run "elydora <command> -h" for details on each command.
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func cmdInstall(args []string) {
|
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)
|
fs := flag.NewFlagSet("install", flag.ExitOnError)
|
||||||
agent := fs.String("agent", "", "Agent name (required). Use 'elydora agents' to list.")
|
agent := fs.String("agent", "", "Agent name (required). Use 'elydora agents' to list.")
|
||||||
orgID := fs.String("org-id", "", "Organization ID (required)")
|
orgID := fs.String("org-id", "", "Organization ID (required)")
|
||||||
agentID := fs.String("agent-id", "", "Agent 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 <agent-id>-key-1)")
|
kid := fs.String("kid", "", "Key ID (defaults to <agent-id>-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")
|
baseURL := fs.String("base-url", "https://api.elydora.com", "Elydora API base URL")
|
||||||
|
|
||||||
fs.Parse(args)
|
fs.Parse(args)
|
||||||
|
|
||||||
if *agent == "" || *orgID == "" || *agentID == "" || *privateKey == "" {
|
if *agent == "" || *orgID == "" || *agentID == "" {
|
||||||
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, --agent-id, and --private-key are required.")
|
fmt.Fprintln(os.Stderr, "Error: --agent, --org-id, and --agent-id are required.")
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
os.Exit(1)
|
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)
|
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
|
||||||
os.Exit(1)
|
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)
|
// Generate guard script (PreToolUse — freeze enforcement)
|
||||||
agentDirectory, err := plugins.PrepareAgentRuntimeDirectory(*agentID)
|
agentDirectory, err := plugins.PrepareAgentRuntimeDirectory(*agentID)
|
||||||
@@ -98,7 +123,12 @@ func cmdInstall(args []string) {
|
|||||||
}
|
}
|
||||||
guardScriptPath := filepath.Join(agentDirectory, "guard.js")
|
guardScriptPath := filepath.Join(agentDirectory, "guard.js")
|
||||||
guardScript := plugins.GenerateGuardScript(*agent, *agentID)
|
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)
|
fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -108,9 +138,9 @@ func cmdInstall(args []string) {
|
|||||||
AgentName: *agent,
|
AgentName: *agent,
|
||||||
OrgID: *orgID,
|
OrgID: *orgID,
|
||||||
AgentID: *agentID,
|
AgentID: *agentID,
|
||||||
PrivateKey: *privateKey,
|
PrivateKey: secrets.privateKey,
|
||||||
KID: *kid,
|
KID: *kid,
|
||||||
Token: *token,
|
Token: secrets.token,
|
||||||
BaseURL: *baseURL,
|
BaseURL: *baseURL,
|
||||||
GuardScriptPath: guardScriptPath,
|
GuardScriptPath: guardScriptPath,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ func prepareDroidInstallationChanges(
|
|||||||
mode os.FileMode
|
mode os.FileMode
|
||||||
}{
|
}{
|
||||||
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
{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},
|
{auditPath, "Elydora audit runtime", []byte(buildHookScript(droidAgentKey, config.AgentID)), 0700},
|
||||||
}
|
}
|
||||||
changes := make([]*fileChange, 0, len(runtimeChanges)+len(rendered))
|
changes := make([]*fileChange, 0, len(runtimeChanges)+len(rendered))
|
||||||
|
|||||||
@@ -2,71 +2,8 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GenerateHookScript writes a self-contained Node.js hook script to disk.
|
|
||||||
// The script reads config/key from ~/.elydora/<agentId>/ 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/<agentId>/config.json and ~/.elydora/<agentId>/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
|
// GenerateGuardScript returns a self-contained Node.js guard script that checks
|
||||||
// whether the agent is frozen and blocks tool execution if so.
|
// 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
|
// 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 STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
|
||||||
const CACHE_TTL_MS = 60000; // 60 seconds
|
const CACHE_TTL_MS = 60000; // 60 seconds
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
// Consume stdin so the parent process doesn't block on a full pipe
|
// Consume stdin so the parent process doesn't block on a full pipe
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
process.stdin.on('data', () => {});
|
process.stdin.on('data', () => {});
|
||||||
@@ -99,7 +38,11 @@ async function main() {
|
|||||||
// Read agent config
|
// Read agent config
|
||||||
let config;
|
let config;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return; // Can't read config — fail-open (allow)
|
return; // Can't read config — fail-open (allow)
|
||||||
}
|
}
|
||||||
@@ -166,7 +109,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
`, agentName, agentName, agentId)
|
`, agentName, agentName, agentId, protectedRuntimeFileReader)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildHookScript(agentName string, agentId string) string {
|
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 CHAIN_STATE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'chain-state.json');
|
||||||
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'error.log');
|
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)
|
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -369,8 +327,12 @@ async function main() {
|
|||||||
let config;
|
let config;
|
||||||
let privateKey;
|
let privateKey;
|
||||||
try {
|
try {
|
||||||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
config = JSON.parse(readProtectedFile(
|
||||||
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
|
CONFIG_PATH,
|
||||||
|
'Agent config',
|
||||||
|
MAX_PROTECTED_CONFIG_BYTES,
|
||||||
|
).toString('utf-8'));
|
||||||
|
privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(new Error('Failed to read agent config/key: ' + err.message));
|
logError(new Error('Failed to read agent config/key: ' + err.message));
|
||||||
return;
|
return;
|
||||||
@@ -471,5 +433,5 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
`, agentName, agentName, agentId)
|
`, agentName, agentName, agentId, protectedRuntimeFileReader)
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ func prepareQwenInstallationChanges(
|
|||||||
mode os.FileMode
|
mode os.FileMode
|
||||||
}{
|
}{
|
||||||
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
{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},
|
{auditPath, "Elydora audit runtime", []byte(buildHookScript(qwenAgentKey, config.AgentID)), 0700},
|
||||||
}
|
}
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
changes := make([]*fileChange, 0, len(items)+1)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
+6
-1
@@ -4,4 +4,9 @@ go 1.21.0
|
|||||||
|
|
||||||
require github.com/pelletier/go-toml/v2 v2.4.3
|
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
|
||||||
@@ -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/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 h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
|
||||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
|
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=
|
||||||
Reference in new issue
Block a user