fix(go): protect CLI install credentials

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 00:23:03 -04:00
1 parent 4f9108099c
commit 5384565b4f
16 files changed
+1126 -79

No files matched your search

+232
View File
@@ -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
}
+212
View File
@@ -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)
}
}
}
+37 -7
View File
@@ -62,19 +62,24 @@ Run "elydora <command> -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 <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")
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,
}
+1 -1
View File
@@ -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))
+30 -68
View File
@@ -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/<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
// 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)
}
+1 -1
View File
@@ -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)
@@ -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)
+17
View File
@@ -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()))
}
+116
View File
@@ -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{}