fix(cli): protect Go install credentials

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 17:33:34 -04:00
1 parent 3488ab778c
commit 996672a6a7
16 files changed
+1128 -79

No files matched your search

+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)
+145
View File
@@ -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
}
+202
View File
@@ -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)