fix(cline): make Go hook installation transactional

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 11:33:44 -04:00
1 parent bb7186630d
commit 050d0043ea
14 files changed
+1672 -475

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
- Generate Grok guard, config, private key, and audit runtimes with the user hook document in one rollback-capable transaction. Use encoded PowerShell commands on Windows, emit native deny JSON with exit code `2`, preflight physical paths and runtime identity, and require one exact event triple plus strict runtime metadata and a canonical private key for healthy status. - Generate Grok guard, config, private key, and audit runtimes with the user hook document in one rollback-capable transaction. Use encoded PowerShell commands on Windows, emit native deny JSON with exit code `2`, preflight physical paths and runtime identity, and require one exact event triple plus strict runtime metadata and a canonical private key for healthy status.
- Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`. - Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`.
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from matcher validation so recovery remains available offline. Commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact runtime identity, canonical private keys, physical files, and exact wrapper sources during status checks, then verify the effective configuration with `auggie tools list`. - Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from matcher validation so recovery remains available offline. Commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact runtime identity, canonical private keys, physical files, and exact wrapper sources during status checks, then verify the effective configuration with `auggie tools list`.
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control. - Cline CLI 3.0.46 discovers exact `PreToolUse` and `PostToolUse` file basenames across `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, workspace `.clinerules/hooks`, and workspace `.cline/hooks`; Elydora owns the `$CLINE_DIR/hooks` pair and keeps the other roots read-only. Commit both wrappers, generated guard and audit runtimes, strict config, and canonical private key through one rollback-capable transaction. Preserve official input byte-for-byte, emit pure JSON cancellation control for guard exit code `2`, reject linked managed paths, and require exact generated sources plus runtime identity for healthy status. Cline surfaces hook errors and continues the task, so wrapper stderr and runtime `error.log` are operational evidence.
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. - Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction.
- Factory Droid `droid@0.175.0` reads direct event maps from `~/.factory/hooks.json` or the legacy `~/.factory/hooks/hooks.json`; `~/.factory/settings.json` stores the same map under `hooks`. Select the active source per event, preserve JSONC syntax, and direct users to `/hooks` for review. - Factory Droid `droid@0.175.0` reads direct event maps from `~/.factory/hooks.json` or the legacy `~/.factory/hooks/hooks.json`; `~/.factory/settings.json` stores the same map under `hooks`. Select the active source per event, preserve JSONC syntax, and direct users to `/hooks` for review.
- Commit Factory Droid runtime config, private key, audit runtime, and affected hook documents in one rollback-capable transaction. Status requires an enabled `PreToolUse`/`PostToolUse` pair with matching managed runtime files. - Commit Factory Droid runtime config, private key, audit runtime, and affected hook documents in one rollback-capable transaction. Status requires an enabled `PreToolUse`/`PostToolUse` pair with matching managed runtime files.
+1 -1
View File
@@ -89,7 +89,7 @@ Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` comm
GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false. GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false.
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 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hook.js`, `PreToolUse.mjs`, and `PostToolUse.mjs` through one rollback-capable transaction. The native hooks live in `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; the Documents and workspace roots remain read-only. The wrappers forward Cline's complete stdin payload byte-for-byte, use the active Node.js executable for generated runtimes, and emit pure JSON cancellation control for frozen or revoked agents. Status requires physical files, exact generated sources, strict runtime identity, and a canonical private key.
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
const clineCLIPrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
func clineCLIRepository(t *testing.T) string {
t.Helper()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve Cline CLI test source path")
}
return filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", ".."))
}
func buildClineCLIBinary(t *testing.T) string {
t.Helper()
binary := filepath.Join(t.TempDir(), "elydora")
if runtime.GOOS == "windows" {
binary += ".exe"
}
build := exec.Command("go", "build", "-o", binary, "./cmd/elydora")
build.Dir = clineCLIRepository(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build Elydora CLI: %v\n%s", err, output)
}
return binary
}
func runClineCLI(
t *testing.T,
binary string,
workingDirectory string,
environment []string,
args ...string,
) (string, string, int) {
t.Helper()
process := exec.Command(binary, args...)
process.Dir = workingDirectory
process.Env = append(os.Environ(), environment...)
var stdout, stderr strings.Builder
process.Stdout = &stdout
process.Stderr = &stderr
err := process.Run()
if err == nil {
return stdout.String(), stderr.String(), 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return stdout.String(), stderr.String(), exitError.ExitCode()
}
t.Fatalf("run Elydora CLI: %v", err)
return "", "", -1
}
func prepareClineCLIEnvironment(t *testing.T) (string, string, string, []string) {
t.Helper()
root := t.TempDir()
homeDir := filepath.Join(root, "home")
clineDir := filepath.Join(root, "cline-home")
workspace := filepath.Join(root, "workspace")
if err := os.MkdirAll(workspace, 0700); err != nil {
t.Fatalf("create Cline CLI workspace: %v", err)
}
environment := []string{
"HOME=" + homeDir,
"USERPROFILE=" + homeDir,
"CLINE_DIR=" + clineDir,
}
return homeDir, clineDir, workspace, environment
}
func writeClineCLISecret(t *testing.T, path, value string) {
t.Helper()
if err := os.WriteFile(path, []byte(value+"\n"), 0600); err != nil {
t.Fatalf("write Cline CLI secret: %v", err)
}
}
func TestClineCLIPreflightPreservesCollisionBeforeRuntimeCreation(t *testing.T) {
binary := buildClineCLIBinary(t)
homeDir, clineDir, workspace, environment := prepareClineCLIEnvironment(t)
hooksDir := filepath.Join(clineDir, "hooks")
auditPath := filepath.Join(hooksDir, "PostToolUse.mjs")
if err := os.MkdirAll(hooksDir, 0700); err != nil {
t.Fatalf("create Cline hooks directory: %v", err)
}
original := "// user PostToolUse hook\n"
if err := os.WriteFile(auditPath, []byte(original), 0600); err != nil {
t.Fatalf("write user Cline hook: %v", err)
}
keyPath := filepath.Join(t.TempDir(), "private.key")
writeClineCLISecret(t, keyPath, clineCLIPrivateKey)
_, stderr, exit := runClineCLI(
t,
binary,
workspace,
environment,
"install",
"--agent", "cline",
"--org-id", "org-1",
"--agent-id", "agent-1",
"--private-key-file", keyPath,
"--kid", "kid-1",
"--base-url", "https://api.elydora.test",
)
if exit == 0 || !strings.Contains(stderr, "owned by another integration") {
t.Fatalf("CLI preflight = %d, %q", exit, stderr)
}
raw, err := os.ReadFile(auditPath)
if err != nil || string(raw) != original {
t.Fatalf("user Cline hook changed: %q, %v", raw, err)
}
if _, err := os.Lstat(filepath.Join(homeDir, ".elydora")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime directory created during failed preflight: %v", err)
}
if _, err := os.Lstat(filepath.Join(hooksDir, "PreToolUse.mjs")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("guard hook created during failed preflight: %v", err)
}
}
func TestClineCLIInstallStatusAndUninstallEndToEnd(t *testing.T) {
binary := buildClineCLIBinary(t)
homeDir, clineDir, workspace, environment := prepareClineCLIEnvironment(t)
keyPath := filepath.Join(t.TempDir(), "private.key")
tokenPath := filepath.Join(t.TempDir(), "token")
writeClineCLISecret(t, keyPath, clineCLIPrivateKey)
writeClineCLISecret(t, tokenPath, "token-1")
stdout, stderr, exit := runClineCLI(
t,
binary,
workspace,
environment,
"install",
"--agent", "cline",
"--org-id", "org-1",
"--agent-id", "agent-1",
"--private-key-file", keyPath,
"--token-file", tokenPath,
"--kid", "kid-1",
"--base-url", "http://127.0.0.1:9",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Cline") {
t.Fatalf("CLI install = %d, %q, %q", exit, stdout, stderr)
}
agentDir := filepath.Join(homeDir, ".elydora", "agent-1")
for _, path := range []string{
filepath.Join(agentDir, "guard.js"),
filepath.Join(agentDir, "config.json"),
filepath.Join(agentDir, "private.key"),
filepath.Join(agentDir, "hook.js"),
filepath.Join(clineDir, "hooks", "PreToolUse.mjs"),
filepath.Join(clineDir, "hooks", "PostToolUse.mjs"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("installed file missing at %s: %v", path, err)
}
}
stdout, stderr, exit = runClineCLI(
t, binary, workspace, environment, "status", "--agent", "cline",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") {
t.Fatalf("CLI status = %d, %q, %q", exit, stdout, stderr)
}
stdout, stderr, exit = runClineCLI(
t,
binary,
workspace,
environment,
"uninstall",
"--agent", "cline",
"--agent-id", "agent-1",
)
if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") {
t.Fatalf("CLI uninstall = %d, %q, %q", exit, stdout, stderr)
}
for _, path := range []string{
agentDir,
filepath.Join(clineDir, "hooks", "PreToolUse.mjs"),
filepath.Join(clineDir, "hooks", "PostToolUse.mjs"),
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed path remains at %s: %v", path, err)
}
}
}
+57 -83
View File
@@ -3,83 +3,61 @@ package plugins
import "fmt" import "fmt"
// ClinePlugin manages Cline's native global file hooks. // ClinePlugin manages Cline's native global file hooks.
type ClinePlugin struct{} type ClinePlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that Cline commits both generated runtimes and
// both native hook files in one transaction.
func (p *ClinePlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates hook ownership and runtime identity before writes.
func (p *ClinePlugin) PreflightInstall(config InstallConfig) error {
paths, guard, audit, err := readClineHookPair()
if err != nil {
return err
}
if err := requireAvailableClineHook(guard); err != nil {
return err
}
if err := requireAvailableClineHook(audit); err != nil {
return err
}
_, err = preflightClineInstallation(config, paths)
return err
}
func (p *ClinePlugin) Install(config InstallConfig) error { func (p *ClinePlugin) Install(config InstallConfig) error {
if config.AgentID == "" { paths, guard, audit, err := readClineHookPair()
return fmt.Errorf("agent ID is required")
}
paths, err := resolveClineHookFiles()
if err != nil { if err != nil {
return err return err
} }
guardState, err := readClineHookFile(paths.guardPath) if err := requireAvailableClineHook(guard); err != nil {
return err
}
if err := requireAvailableClineHook(audit); err != nil {
return err
}
runtimePaths, err := preflightClineInstallation(config, paths)
if err != nil { if err != nil {
return err return err
} }
auditState, err := readClineHookFile(paths.auditPath) changes, err := prepareClineInstallationChanges(
config,
runtimePaths,
guard,
audit,
)
if err != nil { if err != nil {
return err return err
} }
if err := requireAvailableClineHook(guardState); err != nil { if err := writeClineChanges(
return err changes,
} "Install Cline hooks",
if err := requireAvailableClineHook(auditState); err != nil { p.rename,
return err runtimePaths,
}
if config.GuardScriptPath == "" {
return fmt.Errorf("guard script path is required")
}
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
if err != nil {
return err
}
if !guardExists {
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
}
hookPath, err := hookScriptPath(config.AgentID)
if err != nil {
return err
}
if config.HookScript != "" {
hookPath = config.HookScript
}
guardMetadata, err := buildClineMetadata("guard", config.AgentID, config.GuardScriptPath)
if err != nil {
return fmt.Errorf("build Cline guard metadata: %w", err)
}
auditMetadata, err := buildClineMetadata("audit", config.AgentID, hookPath)
if err != nil {
return fmt.Errorf("build Cline audit metadata: %w", err)
}
guardSource, err := buildClineWrapper(guardMetadata)
if err != nil {
return fmt.Errorf("build Cline guard wrapper: %w", err)
}
auditSource, err := buildClineWrapper(auditMetadata)
if err != nil {
return fmt.Errorf("build Cline audit wrapper: %w", err)
}
if _, err := clineContractForFiles(
clineHookFile{
exists: true, filePath: paths.guardPath,
source: guardSource, metadata: &guardMetadata,
},
clineHookFile{
exists: true, filePath: paths.auditPath,
source: auditSource, metadata: &auditMetadata,
},
); err != nil {
return err
}
runtimeConfig := config
runtimeConfig.AgentName = clineAgentKey
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
if err := writeClineHookPair(
clinePendingWrite{state: guardState, source: guardSource},
clinePendingWrite{state: auditState, source: auditSource},
); err != nil { ); err != nil {
return err return err
} }
@@ -88,23 +66,27 @@ func (p *ClinePlugin) Install(config InstallConfig) error {
} }
func (p *ClinePlugin) Uninstall(agentID string) error { func (p *ClinePlugin) Uninstall(agentID string) error {
paths, err := resolveClineHookFiles() paths, guard, audit, err := readClineHookPair()
if err != nil { if err != nil {
return err return err
} }
guardState, err := readClineHookFile(paths.guardPath) changes, err := prepareClineUninstallChanges(
[]clineHookFile{guard, audit},
agentID,
)
if err != nil { if err != nil {
return err return err
} }
auditState, err := readClineHookFile(paths.auditPath) return writeClineHookChanges(
if err != nil { changes,
return err "Uninstall Cline hooks",
} p.rename,
return removeOwnedClineHooks([]clineHookFile{guardState, auditState}, agentID) paths,
)
} }
func (p *ClinePlugin) Status() (PluginStatus, error) { func (p *ClinePlugin) Status() (PluginStatus, error) {
paths, err := resolveClineHookFiles() paths, guard, audit, err := readClineHookPair()
status := PluginStatus{ status := PluginStatus{
AgentName: clineAgentKey, AgentName: clineAgentKey,
DisplayName: "Cline", DisplayName: "Cline",
@@ -113,15 +95,7 @@ func (p *ClinePlugin) Status() (PluginStatus, error) {
if err != nil { if err != nil {
return status, err return status, err
} }
guardState, err := readClineHookFile(paths.guardPath) contract, err := clineContractForFiles(guard, audit)
if err != nil {
return status, err
}
auditState, err := readClineHookFile(paths.auditPath)
if err != nil {
return status, err
}
contract, err := clineContractForFiles(guardState, auditState)
if err != nil || contract == nil { if err != nil || contract == nil {
return status, err return status, err
} }
+5 -22
View File
@@ -35,6 +35,7 @@ type clineHookFile struct {
} }
type clineHookPaths struct { type clineHookPaths struct {
clineDirectory string
hooksDirectory string hooksDirectory string
guardPath string guardPath string
auditPath string auditPath string
@@ -55,14 +56,6 @@ func clineHomeDirectory() (string, error) {
return home, nil return home, nil
} }
func clineElydoraRoot() (string, error) {
home, err := clineHomeDirectory()
if err != nil {
return "", err
}
return filepath.Join(home, ".elydora"), nil
}
func resolveClineHookFiles() (clineHookPaths, error) { func resolveClineHookFiles() (clineHookPaths, error) {
clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR")) clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR"))
if clineDirectory == "" { if clineDirectory == "" {
@@ -80,6 +73,7 @@ func resolveClineHookFiles() (clineHookPaths, error) {
} }
hooksDirectory := filepath.Join(clineDirectory, "hooks") hooksDirectory := filepath.Join(clineDirectory, "hooks")
return clineHookPaths{ return clineHookPaths{
clineDirectory: clineDirectory,
hooksDirectory: hooksDirectory, hooksDirectory: hooksDirectory,
guardPath: filepath.Join(hooksDirectory, clineGuardFileName), guardPath: filepath.Join(hooksDirectory, clineGuardFileName),
auditPath: filepath.Join(hooksDirectory, clineAuditFileName), auditPath: filepath.Join(hooksDirectory, clineAuditFileName),
@@ -225,7 +219,7 @@ async function main() {
if (hookKind === 'guard' && result.code === 2) { if (hookKind === 'guard' && result.code === 2) {
const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.'; const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.';
process.stdout.write('HOOK_CONTROL\t' + JSON.stringify({ cancel: true, errorMessage }) + '\n'); process.stdout.write(JSON.stringify({ cancel: true, errorMessage }) + '\n');
return; return;
} }
if (result.signal) throw new Error('runtime terminated by signal ' + result.signal); if (result.signal) throw new Error('runtime terminated by signal ' + result.signal);
@@ -303,13 +297,6 @@ func assertClineWrapperIntegrity(file clineHookFile) error {
return nil return nil
} }
func validateClineAgentSegment(agentID string) error {
if agentID == "." || agentID == ".." || filepath.Base(agentID) != agentID {
return fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId")
}
return nil
}
func clineContractForFiles( func clineContractForFiles(
guardFile, auditFile clineHookFile, guardFile, auditFile clineHookFile,
) (*clineRuntimeContract, error) { ) (*clineRuntimeContract, error) {
@@ -330,14 +317,10 @@ func clineContractForFiles(
if !sameClineAgentID(guard.AgentID, audit.AgentID) { if !sameClineAgentID(guard.AgentID, audit.AgentID) {
return nil, fmt.Errorf("Elydora Cline hook files reference different agents") return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
} }
if err := validateClineAgentSegment(guard.AgentID); err != nil { agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
return nil, err
}
runtimeRoot, err := clineElydoraRoot()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId: %w", err)
} }
agentDirectory := filepath.Join(runtimeRoot, guard.AgentID)
expectedGuard := filepath.Join(agentDirectory, clineGuardScript) expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
expectedAudit := filepath.Join(agentDirectory, clineAuditScript) expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard) guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
+219
View File
@@ -0,0 +1,219 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type clineRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
hooks clineHookPaths
}
func validateClineInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != clineAgentKey {
return fmt.Errorf("Cline installation requires agent name %s", clineAgentKey)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func clineAgentPaths(
config InstallConfig,
hooks clineHookPaths,
) (*clineRuntimePaths, error) {
if err := validateClineInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &clineRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, hooks: hooks,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, clineGuardScript),
auditPath: filepath.Join(agentDirectory, clineAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameClinePathValue(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameClinePathValue(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func sameClinePathValue(left, right string) bool {
matches, err := sameClinePath(left, right)
return err == nil && matches
}
func preflightClineInstallation(
config InstallConfig,
hooks clineHookPaths,
) (*clineRuntimePaths, error) {
paths, err := clineAgentPaths(config, hooks)
if err != nil {
return nil, err
}
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, err
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return nil, err
}
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
return nil, fmt.Errorf("Cline hooks require an absolute Node.js executable path")
}
return paths, nil
}
func buildClineRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": clineAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareClineInstallationChanges(
config InstallConfig,
paths *clineRuntimePaths,
guardFile clineHookFile,
auditFile clineHookFile,
) ([]*fileChange, error) {
if paths == nil {
return nil, fmt.Errorf("prepared Cline installation is required")
}
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, err
}
runtimeConfig, err := buildClineRuntimeConfig(config)
if err != nil {
return nil, err
}
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
if err != nil {
return nil, fmt.Errorf("build Cline guard metadata: %w", err)
}
auditMetadata, err := buildClineMetadata("audit", config.AgentID, paths.auditPath)
if err != nil {
return nil, fmt.Errorf("build Cline audit metadata: %w", err)
}
guardWrapper, err := buildClineWrapper(guardMetadata)
if err != nil {
return nil, fmt.Errorf("build Cline guard wrapper: %w", err)
}
auditWrapper, err := buildClineWrapper(auditMetadata)
if err != nil {
return nil, fmt.Errorf("build Cline audit wrapper: %w", err)
}
if _, err := clineContractForFiles(
clineHookFile{exists: true, filePath: guardFile.filePath, source: guardWrapper, metadata: &guardMetadata},
clineHookFile{exists: true, filePath: auditFile.filePath, source: auditWrapper, metadata: &auditMetadata},
); err != nil {
return nil, err
}
runtimeItems := []struct {
path, label string
content []byte
mode os.FileMode
}{
{paths.guardPath, "Elydora guard runtime", []byte(generateGuardScript(clineAgentKey, config.AgentID, "", false, "")), 0700},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{paths.auditPath, "Elydora audit runtime", []byte(buildHookScriptWithOutput(clineAgentKey, config.AgentID, "", false, true)), 0700},
}
changes := make([]*fileChange, 0, len(runtimeItems)+2)
for _, item := range runtimeItems {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
for _, item := range []struct {
file clineHookFile
label string
source string
}{
{guardFile, "Cline PreToolUse hook", guardWrapper},
{auditFile, "Cline PostToolUse hook", auditWrapper},
} {
change, err := prepareSourceChange(
item.file.filePath,
item.label,
[]byte(item.file.source),
item.file.exists,
[]byte(item.source),
0700,
false,
)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
return changes, nil
}
+106 -170
View File
@@ -1,47 +1,63 @@
package plugins package plugins
import ( import "fmt"
"errors"
"fmt" func validateClineHookDirectories(paths clineHookPaths) error {
"io" if _, err := managedPhysicalDirectoryExists(
"os" paths.clineDirectory,
"path/filepath" "Cline configuration directory",
); err != nil {
return err
}
_, err := managedPhysicalDirectoryExists(
paths.hooksDirectory,
"Cline hooks directory",
) )
return err
type clinePendingWrite struct {
state clineHookFile
source string
} }
type clineStagedFile struct {
state clineHookFile
temporaryPath string
}
type clineRenameFunc func(source, destination string) error
func readClineHookFile(filePath string) (clineHookFile, error) { func readClineHookFile(filePath string) (clineHookFile, error) {
// #nosec G304 -- filePath is resolved from Cline's native hook directory. snapshot, err := readManagedFile(filePath, "Cline hook", maxManagedSourceBytes)
raw, err := os.ReadFile(filePath)
if err != nil { if err != nil {
if os.IsNotExist(err) { return clineHookFile{}, err
}
if snapshot == nil {
return clineHookFile{filePath: filePath}, nil return clineHookFile{filePath: filePath}, nil
} }
return clineHookFile{}, fmt.Errorf("read Cline hook at %s: %w", filePath, err) source := string(snapshot.contents)
}
source := string(raw)
metadata, err := parseClineMetadata(filePath, source) metadata, err := parseClineMetadata(filePath, source)
if err != nil { if err != nil {
return clineHookFile{}, err return clineHookFile{}, err
} }
return clineHookFile{ return clineHookFile{
exists: true, exists: true, filePath: filePath, source: source, metadata: metadata,
filePath: filePath,
source: source,
metadata: metadata,
}, nil }, nil
} }
func readClineHookPair() (
clineHookPaths,
clineHookFile,
clineHookFile,
error,
) {
paths, err := resolveClineHookFiles()
if err != nil {
return clineHookPaths{}, clineHookFile{}, clineHookFile{}, err
}
if err := validateClineHookDirectories(paths); err != nil {
return paths, clineHookFile{}, clineHookFile{}, err
}
guard, err := readClineHookFile(paths.guardPath)
if err != nil {
return paths, clineHookFile{}, clineHookFile{}, err
}
audit, err := readClineHookFile(paths.auditPath)
if err != nil {
return paths, clineHookFile{}, clineHookFile{}, err
}
return paths, guard, audit, nil
}
func requireAvailableClineHook(file clineHookFile) error { func requireAvailableClineHook(file clineHookFile) error {
if file.exists && file.metadata == nil { if file.exists && file.metadata == nil {
return fmt.Errorf( return fmt.Errorf(
@@ -52,160 +68,80 @@ func requireAvailableClineHook(file clineHookFile) error {
return nil return nil
} }
func removeClineTemporary(path string) error { func writeClineHookChanges(
if path == "" { changes []*fileChange,
return nil label string,
} rename renameFunc,
if err := os.Remove(path); err != nil && !os.IsNotExist(err) { paths clineHookPaths,
return fmt.Errorf("remove temporary Cline hook at %s: %w", path, err)
}
return nil
}
func failClineStage(temporary *os.File, path string, cause error) error {
closeErr := temporary.Close()
removeErr := removeClineTemporary(path)
return errors.Join(cause, closeErr, removeErr)
}
func stageClineHook(write clinePendingWrite) (clineStagedFile, error) {
directory := filepath.Dir(write.state.filePath)
if err := os.MkdirAll(directory, 0700); err != nil {
return clineStagedFile{}, fmt.Errorf("create Cline hooks directory at %s: %w", directory, err)
}
temporary, err := os.CreateTemp(directory, "."+filepath.Base(write.state.filePath)+".*.tmp")
if err != nil {
return clineStagedFile{}, fmt.Errorf("stage Cline hook at %s: %w", write.state.filePath, err)
}
temporaryPath := temporary.Name()
if err := temporary.Chmod(0700); err != nil {
return clineStagedFile{}, failClineStage(
temporary,
temporaryPath,
fmt.Errorf("set permissions for %s: %w", temporaryPath, err),
)
}
written, err := temporary.WriteString(write.source)
if err != nil {
return clineStagedFile{}, failClineStage(
temporary,
temporaryPath,
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, err),
)
}
if written != len(write.source) {
return clineStagedFile{}, failClineStage(
temporary,
temporaryPath,
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, io.ErrShortWrite),
)
}
if err := temporary.Sync(); err != nil {
return clineStagedFile{}, failClineStage(
temporary,
temporaryPath,
fmt.Errorf("sync temporary Cline hook for %s: %w", write.state.filePath, err),
)
}
if err := temporary.Close(); err != nil {
return clineStagedFile{}, errors.Join(
fmt.Errorf("close temporary Cline hook for %s: %w", write.state.filePath, err),
removeClineTemporary(temporaryPath),
)
}
return clineStagedFile{state: write.state, temporaryPath: temporaryPath}, nil
}
func rollbackClineHook(state clineHookFile) error {
if state.exists {
return writeHookFileAtomic(state.filePath, []byte(state.source), 0700)
}
return removeHookFile(state.filePath, "Cline hook rollback")
}
func writeClineHookPair(guard, audit clinePendingWrite) error {
return writeClineHookPairWithRename(guard, audit, os.Rename)
}
func writeClineHookPairWithRename(
guard, audit clinePendingWrite,
rename clineRenameFunc,
) error { ) error {
writes := []clinePendingWrite{guard, audit} if !hasClineChanges(changes) {
staged := make([]clineStagedFile, 0, len(writes))
for _, write := range writes {
file, err := stageClineHook(write)
if err != nil {
failures := []error{fmt.Errorf("stage Cline hook pair: %w", err)}
for _, item := range staged {
if cleanupErr := removeClineTemporary(item.temporaryPath); cleanupErr != nil {
failures = append(failures, cleanupErr)
}
}
return errors.Join(failures...)
}
staged = append(staged, file)
}
committed := 0
for index, item := range staged {
if err := rename(item.temporaryPath, item.state.filePath); err != nil {
failures := []error{
fmt.Errorf("write Cline hook pair at %s: %w", item.state.filePath, err),
}
for rollbackIndex := committed - 1; rollbackIndex >= 0; rollbackIndex-- {
if rollbackErr := rollbackClineHook(staged[rollbackIndex].state); rollbackErr != nil {
failures = append(failures, fmt.Errorf(
"restore Cline hook at %s: %w",
staged[rollbackIndex].state.filePath,
rollbackErr,
))
}
}
for cleanupIndex := index; cleanupIndex < len(staged); cleanupIndex++ {
if cleanupErr := removeClineTemporary(staged[cleanupIndex].temporaryPath); cleanupErr != nil {
failures = append(failures, cleanupErr)
}
}
return errors.Join(failures...)
}
committed++
}
return nil return nil
} }
if err := ensureManagedDirectory(
func removeOwnedClineHooks(files []clineHookFile, agentID string) error { paths.clineDirectory,
for _, file := range files { "Cline configuration directory",
if file.metadata == nil { ); err != nil {
continue
}
if agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID) {
continue
}
if err := removeHookFile(file.filePath, "Cline hook"); err != nil {
return err return err
} }
if err := ensureManagedDirectory(paths.hooksDirectory, "Cline hooks directory"); err != nil {
return err
} }
return nil return writeChanges(changes, label, rename)
} }
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) { func writeClineChanges(
configPath := filepath.Join(contract.agentDirectory, "config.json") changes []*fileChange,
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config") label string,
if err != nil || !exists { rename renameFunc,
return false, err paths *clineRuntimePaths,
) error {
if !hasClineChanges(changes) {
return nil
} }
agentID, ok := config["agent_id"].(string) if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
if config["agent_name"] != clineAgentKey || !ok || !sameClineAgentID(agentID, contract.agentID) { return err
return false, nil
} }
guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime") if err := EnsurePrivateDirectory(paths.agentDirectory); err != nil {
return err
}
return writeClineHookChanges(changes, label, rename, paths.hooks)
}
func hasClineChanges(changes []*fileChange) bool {
for _, change := range changes {
if change != nil {
return true
}
}
return false
}
func prepareClineUninstallChanges(
files []clineHookFile,
agentID string,
) ([]*fileChange, error) {
changes := make([]*fileChange, 0, len(files))
for _, file := range files {
if file.metadata == nil ||
(agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) {
continue
}
if err := assertClineWrapperIntegrity(file); err != nil {
return nil, err
}
change, err := prepareSourceChange(
file.filePath,
fmt.Sprintf("Cline %s hook", file.metadata.Kind),
[]byte(file.source),
file.exists,
nil,
0700,
true,
)
if err != nil { if err != nil {
return false, err return nil, err
} }
auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime") changes = append(changes, change)
if err != nil {
return false, err
} }
return guardExists && auditExists, nil return changes, nil
} }
@@ -0,0 +1,81 @@
package plugins
import (
"encoding/json"
"os"
"os/exec"
"reflect"
"strings"
"testing"
)
func runOfficialCline(
t *testing.T,
fixture *clineFixture,
nodePath string,
args ...string,
) (string, error) {
t.Helper()
process := exec.Command(nodePath, args...)
process.Dir = fixture.workspaceDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"CLINE_DIR="+fixture.clineDir,
)
output, err := process.CombinedOutput()
return string(output), err
}
func TestClineOfficialLoaderDiscoversBothManagedFileHooks(t *testing.T) {
coreEntry := os.Getenv("ELYDORA_CLINE_CORE_ENTRY")
clineEntry := os.Getenv("ELYDORA_CLINE_ENTRY")
if coreEntry == "" || clineEntry == "" {
t.Skip("set ELYDORA_CLINE_CORE_ENTRY and ELYDORA_CLINE_ENTRY")
}
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
version, err := runOfficialCline(t, fixture, nodePath, clineEntry, "--version")
foundVersion := false
for _, line := range strings.Split(strings.ReplaceAll(version, "\r\n", "\n"), "\n") {
foundVersion = foundVersion || strings.TrimSpace(line) == "3.0.46"
}
if err != nil || !foundVersion {
t.Fatalf("official Cline version = %q, %v", version, err)
}
source := `
import { pathToFileURL } from 'node:url';
const { listHookConfigFiles } = await import(pathToFileURL(process.env.ELYDORA_CLINE_CORE_ENTRY).href);
console.log(JSON.stringify(listHookConfigFiles(process.env.ELYDORA_WORKSPACE)));
`
process := exec.Command(nodePath, "--input-type=module", "--eval", source)
process.Dir = fixture.workspaceDir
process.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"CLINE_DIR="+fixture.clineDir,
"ELYDORA_CLINE_CORE_ENTRY="+coreEntry,
"ELYDORA_WORKSPACE="+fixture.workspaceDir,
)
output, err := process.CombinedOutput()
if err != nil {
t.Fatalf("official Cline hook discovery: %v\n%s", err, output)
}
var actual []map[string]any
if err := json.Unmarshal(output, &actual); err != nil {
t.Fatalf("decode official Cline hook discovery: %v\n%s", err, output)
}
expected := []map[string]any{
{"fileName": "PostToolUse", "hookEventName": "tool_result", "path": fixture.auditWrapper},
{"fileName": "PreToolUse", "hookEventName": "tool_call", "path": fixture.guardWrapper},
}
if !reflect.DeepEqual(actual, expected) {
t.Fatalf("official Cline hooks = %#v", actual)
}
}
+147 -149
View File
@@ -8,119 +8,174 @@ import (
"testing" "testing"
) )
func TestClineStatusRequiresIntactHooksAndRuntimes(t *testing.T) { func TestClineStatusRequiresExactPhysicalInstallation(t *testing.T) {
for _, target := range []string{
"config", "key", "guard", "audit", "guard-wrapper", "audit-wrapper",
} {
t.Run("missing "+target, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{}) fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture) installClineFixture(t, fixture)
status, err := fixture.plugin.Status() status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err) t.Fatalf("installed status = %#v, %v", status, err)
} }
if status.AgentName != "cline" || status.DisplayName != "Cline" || status.ConfigPath != fixture.hooksDir { paths := map[string]string{
t.Fatalf("status metadata = %#v", status) "config": fixture.runtimeConfig, "key": fixture.privateKey,
"guard": fixture.guardPath, "audit": fixture.hookPath,
"guard-wrapper": fixture.guardWrapper,
"audit-wrapper": fixture.auditWrapper,
} }
if err := os.Remove(fixture.guardPath); err != nil { if err := os.Remove(paths[target]); err != nil {
t.Fatalf("remove guard runtime: %v", err) t.Fatalf("remove %s: %v", target, err)
} }
status, err = fixture.plugin.Status() status, err = fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { if err != nil || status.Installed || status.HookScriptExists {
t.Fatalf("degraded status = %#v, %v", status, err) t.Fatalf("missing %s status = %#v, %v", target, status, err)
} }
if err := os.WriteFile(fixture.guardPath, []byte("process.exit(0);\n"), 0700); err != nil { if strings.Contains(target, "wrapper") && status.HookConfigured {
t.Fatalf("restore guard runtime: %v", err) t.Fatalf("missing wrapper remains configured: %#v", status)
}
if err := os.Remove(fixture.auditWrapper); err != nil {
t.Fatalf("remove audit wrapper: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("partial status = %#v, %v", status, err)
}
}
func TestClineStatusSurfacesCorruptHooksAndRuntimeMetadata(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
guard := readClineTestFile(t, fixture.guardWrapper)
if err := os.WriteFile(fixture.guardWrapper, []byte(guard+"\n// tampered\n"), 0700); err != nil {
t.Fatalf("tamper guard wrapper: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "managed template") {
t.Fatalf("tampered status error = %v", err)
}
installClineFixture(t, fixture)
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
t.Fatalf("runtime status error = %v", err)
}
}
func TestClineInstallRejectsUserFilenameCollisionsBeforeWrites(t *testing.T) {
for _, collision := range []string{"guard", "audit"} {
t.Run(collision, func(t *testing.T) {
options := clineFixtureOptions{}
if collision == "guard" {
options.ExistingGuard = clineString("// user PreToolUse hook\n")
} else {
options.ExistingAudit = clineString("// user PostToolUse hook\n")
}
fixture := prepareClineFixture(t, options)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "owned by another integration") {
t.Fatalf("install error = %v", err)
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
requireMissingClineTestFile(t, path)
}
if options.ExistingGuard != nil && readClineTestFile(t, fixture.guardWrapper) != *options.ExistingGuard {
t.Fatalf("user guard wrapper changed")
}
if options.ExistingAudit != nil && readClineTestFile(t, fixture.auditWrapper) != *options.ExistingAudit {
t.Fatalf("user audit wrapper changed")
} }
}) })
} }
} }
func TestClineInstallPreservesCorruptOwnedMetadataForRecovery(t *testing.T) { func TestClineStatusSurfacesTamperingAndInvalidRuntimeMetadata(t *testing.T) {
corrupt := "#!/usr/bin/env node\n// @elydora-cline-hook invalid\n" tests := []struct {
fixture := prepareClineFixture(t, clineFixtureOptions{ExistingGuard: &corrupt}) name, target, source, want string
err := fixture.plugin.Install(fixture.config) }{
if err == nil || !strings.Contains(err.Error(), "parse Elydora Cline hook metadata") { {"guard-source", "guard", "tampered\n", ""},
t.Fatalf("install error = %v", err) {"audit-source", "audit", "tampered\n", ""},
{"private-key", "key", "invalid", "canonical 32-byte"},
{"config-malformed", "config", "{ malformed", "parse Elydora runtime config"},
{"config-duplicate", "config", `{"agent_name":"cline","agent_name":"cline"}`, "duplicate key"},
{"config-unsupported", "config", `{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"cline","extra":true}`, "unsupported field"},
{"config-identity", "config", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"cline"}`, "identity does not match"},
{"guard-wrapper", "guard-wrapper", "tamper", "managed template"},
} }
if readClineTestFile(t, fixture.guardWrapper) != corrupt { for _, testCase := range tests {
t.Fatalf("corrupt hook changed") t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
paths := map[string]string{
"guard": fixture.guardPath, "audit": fixture.hookPath,
"key": fixture.privateKey, "config": fixture.runtimeConfig,
"guard-wrapper": fixture.guardWrapper,
}
source := testCase.source
if testCase.target == "guard-wrapper" {
source = readClineTestFile(t, paths[testCase.target]) + testCase.source
}
writeClineTestFile(t, paths[testCase.target], []byte(source), 0600)
status, err := fixture.plugin.Status()
if testCase.want == "" {
if err != nil || status.Installed || status.HookScriptExists {
t.Fatalf("tampered status = %#v, %v", status, err)
}
return
}
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("status error = %v, want %q", err, testCase.want)
}
})
} }
requireMissingClineTestFile(t, fixture.auditWrapper)
requireMissingClineTestFile(t, fixture.hookPath)
} }
func TestClineInstallRejectsMissingGuardBeforeWrites(t *testing.T) { func TestClineStatusRejectsInvalidHookContracts(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{SkipGuard: true}) for _, testCase := range []struct {
name, kind, agentID, runtimePath, want string
}{
{"kind", "guard", clineTestAgentID, "", "mismatched event metadata"},
{"agent", "audit", "agent-2", "", "different agents"},
{"path", "audit", clineTestAgentID, "outside", "unexpected runtime path"},
{"segment", "audit", "..", "", "invalid agentId"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
runtimePath := testCase.runtimePath
if runtimePath == "outside" {
runtimePath = filepath.Join(filepath.Dir(filepath.Dir(fixture.agentDir)), "outside", clineAuditScript)
}
if runtimePath == "" {
runtimePath = fixture.hookPath
if testCase.agentID == "agent-2" {
runtimePath = filepath.Join(filepath.Dir(fixture.agentDir), "agent-2", clineAuditScript)
}
}
metadata, err := buildClineMetadata(
testCase.kind,
testCase.agentID,
runtimePath,
)
if err != nil {
t.Fatalf("build invalid contract metadata: %v", err)
}
source, err := buildClineWrapper(metadata)
if err != nil {
t.Fatalf("build invalid contract wrapper: %v", err)
}
writeClineTestFile(t, fixture.auditWrapper, []byte(source), 0700)
if testCase.name == "segment" {
guardMetadata, buildErr := buildClineMetadata(
"guard",
testCase.agentID,
fixture.guardPath,
)
if buildErr != nil {
t.Fatalf("build invalid guard metadata: %v", buildErr)
}
guardSource, buildErr := buildClineWrapper(guardMetadata)
if buildErr != nil {
t.Fatalf("build invalid guard wrapper: %v", buildErr)
}
writeClineTestFile(t, fixture.guardWrapper, []byte(guardSource), 0700)
}
_, err = fixture.plugin.Status()
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("contract error = %v, want %q", err, testCase.want)
}
})
}
}
func TestClineInstallRejectsCollisionsAndCorruptOwnershipBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
opts clineFixtureOptions
want string
}{
{"guard-collision", clineFixtureOptions{ExistingGuard: clineString("// user PreToolUse hook\n")}, "owned by another integration"},
{"audit-collision", clineFixtureOptions{ExistingAudit: clineString("// user PostToolUse hook\n")}, "owned by another integration"},
{"corrupt-metadata", clineFixtureOptions{ExistingGuard: clineString("#!/usr/bin/env node\n// @elydora-cline-hook invalid\n")}, "parse Elydora Cline hook metadata"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClineFixture(t, testCase.opts)
err := fixture.plugin.Install(fixture.config) err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") { if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v", err) t.Fatalf("install error = %v", err)
} }
for path, expected := range map[string]*string{
fixture.guardWrapper: testCase.opts.ExistingGuard,
fixture.auditWrapper: testCase.opts.ExistingAudit,
} {
if expected != nil && readClineTestFile(t, path) != *expected {
t.Fatalf("existing hook changed at %s", path)
}
}
for _, path := range []string{ for _, path := range []string{
fixture.guardWrapper, fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
fixture.auditWrapper,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
} { } {
requireMissingClineTestFile(t, path) requireMissingClineTestFile(t, path)
} }
})
}
} }
func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) { func TestClineUninstallRemovesExactOwnershipAndPreservesAdjacentHooks(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{}) fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture) installClineFixture(t, fixture)
userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py") userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py")
if err := os.WriteFile(userHook, []byte("# user hook\n"), 0600); err != nil { writeClineTestFile(t, userHook, []byte("# user hook\n"), 0600)
t.Fatalf("write user hook: %v", err)
}
if err := fixture.plugin.Uninstall("agent-10"); err != nil { if err := fixture.plugin.Uninstall("agent-10"); err != nil {
t.Fatalf("uninstall other agent: %v", err) t.Fatalf("uninstall other agent: %v", err)
} }
@@ -129,78 +184,21 @@ func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T)
if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil { if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil {
t.Fatalf("uninstall Cline hooks: %v", err) t.Fatalf("uninstall Cline hooks: %v", err)
} }
requireMissingClineTestFile(t, fixture.guardWrapper) for _, path := range []string{fixture.guardWrapper, fixture.auditWrapper} {
requireMissingClineTestFile(t, fixture.auditWrapper) if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed hook remains at %s: %v", path, err)
}
}
if readClineTestFile(t, userHook) != "# user hook\n" { if readClineTestFile(t, userHook) != "# user hook\n" {
t.Fatalf("user hook changed") t.Fatal("adjacent user hook changed")
} }
} }
func TestClineHookPairRollsBackFirstCommitWhenSecondCommitFails(t *testing.T) { func TestClineRuntimeConfigOmitsEmptyToken(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{}) fixture := prepareClineFixture(t, clineFixtureOptions{})
fixture.config.Token = ""
installClineFixture(t, fixture) installClineFixture(t, fixture)
originalGuard := readClineTestFile(t, fixture.guardWrapper) if _, exists := readClineTestObject(t, fixture.runtimeConfig)["token"]; exists {
originalAudit := readClineTestFile(t, fixture.auditWrapper) t.Fatal("empty token persisted")
guardState, err := readClineHookFile(fixture.guardWrapper)
if err != nil {
t.Fatalf("read guard state: %v", err)
}
auditState, err := readClineHookFile(fixture.auditWrapper)
if err != nil {
t.Fatalf("read audit state: %v", err)
}
guardMetadata, err := buildClineMetadata("guard", "agent-2", fixture.guardPath)
if err != nil {
t.Fatalf("build replacement guard metadata: %v", err)
}
auditMetadata, err := buildClineMetadata("audit", "agent-2", fixture.hookPath)
if err != nil {
t.Fatalf("build replacement audit metadata: %v", err)
}
replacementGuard, err := buildClineWrapper(guardMetadata)
if err != nil {
t.Fatalf("build replacement guard: %v", err)
}
replacementAudit, err := buildClineWrapper(auditMetadata)
if err != nil {
t.Fatalf("build replacement audit: %v", err)
}
failureInjected := false
rename := func(source, destination string) error {
if !failureInjected && sameClineTestPath(destination, fixture.auditWrapper) {
failureInjected = true
return errors.New("simulated audit commit failure")
}
return os.Rename(source, destination)
}
err = writeClineHookPairWithRename(
clinePendingWrite{state: guardState, source: replacementGuard},
clinePendingWrite{state: auditState, source: replacementAudit},
rename,
)
if err == nil || !strings.Contains(err.Error(), "write Cline hook pair") {
t.Fatalf("write pair error = %v", err)
}
if !failureInjected {
t.Fatalf("audit commit failure was not injected")
}
if readClineTestFile(t, fixture.guardWrapper) != originalGuard ||
readClineTestFile(t, fixture.auditWrapper) != originalAudit {
t.Fatalf("hook pair was not restored")
}
entries, err := os.ReadDir(fixture.hooksDir)
if err != nil {
t.Fatalf("read hook directory: %v", err)
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".tmp") {
t.Fatalf("temporary hook remains: %s", entry.Name())
} }
} }
}
func sameClineTestPath(left, right string) bool {
leftPath, leftErr := filepath.Abs(left)
rightPath, rightErr := filepath.Abs(right)
return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath)
}
+96
View File
@@ -0,0 +1,96 @@
package plugins
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func clineOfficialPayload(event string) map[string]any {
payload := map[string]any{
"clineVersion": "3.0.46",
"hookName": event,
"timestamp": "2026-07-19T12:00:00.000Z",
"taskId": "task-1",
"workspaceRoots": []any{
"C:/workspace",
},
"userId": "user-1",
"agent_id": "cline-agent",
"parent_agent_id": nil,
}
if event == "tool_call" {
payload["tool_call"] = map[string]any{
"id": "call-1", "name": "read_file",
"input": map[string]any{"path": "README.md"},
}
payload["preToolUse"] = map[string]any{
"toolName": "read_file",
"parameters": map[string]any{"path": "README.md"},
}
}
return payload
}
func marshalClinePayload(t *testing.T, payload map[string]any) []byte {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal Cline payload: %v", err)
}
return encoded
}
func installClineRuntimeFixture(
t *testing.T,
status string,
) (*clineFixture, *codexTestAPI, *httptest.Server) {
t.Helper()
api := &codexTestAPI{status: status}
server := httptest.NewServer(api)
fixture := prepareClineFixture(t, clineFixtureOptions{})
fixture.config.BaseURL = server.URL
if err := fixture.plugin.Install(fixture.config); err != nil {
server.Close()
t.Fatalf("install Cline hooks: %v", err)
}
return fixture, api, server
}
func TestClineGeneratedGuardEmitsPureCancellationForFrozenAndRevokedAgents(t *testing.T) {
for _, status := range []string{"frozen", "revoked"} {
t.Run(status, func(t *testing.T) {
fixture, _, server := installClineRuntimeFixture(t, status)
defer server.Close()
result := runClineWrapper(
t,
fixture,
fixture.guardWrapper,
marshalClinePayload(t, clineOfficialPayload("tool_call")),
)
control := decodeClineControl(t, result.stdout)
if result.exitCode != 0 || control["cancel"] != true ||
!strings.Contains(strings.ToLower(result.stderr), status) ||
!strings.Contains(result.stderr, "Tool execution blocked") {
t.Fatalf("guard result = %#v, %#v", result, control)
}
})
}
}
func TestClineGeneratedRuntimesKeepFailuresObservableAndFailOpen(t *testing.T) {
fixture, _, server := installClineRuntimeFixture(t, "active")
payload := marshalClinePayload(t, clineOfficialPayload("tool_call"))
server.Close()
guard := runClineWrapper(t, fixture, fixture.guardWrapper, payload)
audit := runClineWrapper(t, fixture, fixture.auditWrapper, []byte("{ malformed"))
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if guard.exitCode != 0 || !strings.Contains(guard.stderr, "Failed to resolve agent status") ||
audit.exitCode != 0 || err != nil ||
!strings.Contains(strings.ToLower(string(log)), "invalid json") {
t.Fatalf("fail-open results = %#v, %#v, log=%q, err=%v", guard, audit, log, err)
}
}
+206
View File
@@ -0,0 +1,206 @@
package plugins
import (
"bytes"
"fmt"
"path/filepath"
"sort"
"strings"
)
func readClineRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireClineRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateClineRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireClineRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireClineRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireClineRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameClineAgentID(agentID, expectedAgentID) || config["agent_name"] != clineAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Cline hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireClineRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireClineRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateClineRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readClineRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, clineGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, clineAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != clineAgentKey ||
!sameClineAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Cline agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
if !sameClinePathValue(filepath.Dir(contract.agentDirectory), runtimeRoot) ||
!sameClinePathValue(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) ||
!sameClinePathValue(contract.auditPath, filepath.Join(contract.agentDirectory, clineAuditScript)) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
contract.agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(contract.agentDirectory, "config.json")
keyPath := filepath.Join(contract.agentDirectory, "private.key")
config, configExists, err := readClineRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateClineRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(clineAgentKey, contract.agentID, "", false, ""))
expectedAudit := []byte(buildHookScriptWithOutput(clineAgentKey, contract.agentID, "", false, true))
return bytes.Equal(guard.contents, expectedGuard) && bytes.Equal(audit.contents, expectedAudit), nil
}
+93 -30
View File
@@ -17,37 +17,72 @@ func TestClineRegistryUsesNativeGlobalHookDirectory(t *testing.T) {
if entry.Name != "Cline" || entry.ConfigDir != "~/.cline/hooks" || entry.ConfigFile != "PreToolUse.mjs" { if entry.Name != "Cline" || entry.ConfigDir != "~/.cline/hooks" || entry.ConfigFile != "PreToolUse.mjs" {
t.Fatalf("Cline registry entry = %#v", entry) t.Fatalf("Cline registry entry = %#v", entry)
} }
if _, ok := NewPlugin("cline").(*ClinePlugin); !ok { plugin, ok := NewPlugin("cline").(*ClinePlugin)
if !ok {
t.Fatalf("Cline plugin is not registered") t.Fatalf("Cline plugin is not registered")
} }
if !plugin.ManagesGuardRuntime() {
t.Fatal("Cline plugin must own its runtime transaction")
}
} }
func TestClineInstallWritesOnlyNativeGlobalHooksAndIsIdempotent(t *testing.T) { func TestClineInstallWritesAllSixManagedFilesAtomicallyAndIsIdempotent(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{}) fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture) installClineFixture(t, fixture)
originalGuard := readClineTestFile(t, fixture.guardWrapper) files := []string{
originalAudit := readClineTestFile(t, fixture.auditWrapper) fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
}
original := make(map[string]string, len(files))
for _, path := range files {
original[path] = readClineTestFile(t, path)
}
installClineFixture(t, fixture) installClineFixture(t, fixture)
if readClineTestFile(t, fixture.guardWrapper) != originalGuard { for path, source := range original {
t.Fatalf("guard wrapper changed during idempotent install") if readClineTestFile(t, path) != source {
} t.Fatalf("%s changed during idempotent install", path)
if readClineTestFile(t, fixture.auditWrapper) != originalAudit {
t.Fatalf("audit wrapper changed during idempotent install")
}
if !strings.HasPrefix(originalGuard, "#!/usr/bin/env node\n// @elydora-cline-hook ") {
t.Fatalf("guard wrapper has unexpected header: %q", originalGuard)
}
entries, err := os.ReadDir(fixture.hooksDir)
if err != nil {
t.Fatalf("read Cline hook directory: %v", err)
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".tmp") {
t.Fatalf("temporary hook file remains: %s", entry.Name())
} }
} }
if !strings.HasPrefix(original[fixture.guardWrapper], "#!/usr/bin/env node\n// @elydora-cline-hook ") {
t.Fatalf("guard wrapper has unexpected header: %q", original[fixture.guardWrapper])
}
if original[fixture.guardPath] != generateGuardScript(
clineAgentKey,
clineTestAgentID,
"",
false,
"",
) {
t.Fatal("guard runtime does not match the generated source")
}
if original[fixture.hookPath] != buildHookScriptWithOutput(
clineAgentKey,
clineTestAgentID,
"",
false,
true,
) {
t.Fatal("audit runtime does not preserve the native payload")
}
if original[fixture.privateKey] != clinePrivateKey {
t.Fatal("private key changed during installation")
}
config := readClineTestObject(t, fixture.runtimeConfig)
wantConfig := map[string]any{
"org_id": "org-1", "agent_id": clineTestAgentID, "kid": "kid-1",
"base_url": "https://api.elydora.test", "token": "token-1",
"agent_name": clineAgentKey,
}
if !reflect.DeepEqual(config, wantConfig) {
t.Fatalf("runtime config = %#v", config)
}
assertNoClineTransactionArtifacts(t, fixture.homeDir)
requireMissingClineTestFile(t, filepath.Join(fixture.homeDir, "Documents", "Cline", "Hooks", "PreToolUse.mjs")) requireMissingClineTestFile(t, filepath.Join(fixture.homeDir, "Documents", "Cline", "Hooks", "PreToolUse.mjs"))
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs")) requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs"))
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".clinerules", "hooks", "PreToolUse.mjs")) requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".clinerules", "hooks", "PreToolUse.mjs"))
@@ -67,6 +102,13 @@ func TestClineInstallUsesOfficialDefaultWhenClineDirIsEmpty(t *testing.T) {
func TestClineWrappersTranslateFreezeAndForwardPayloadByteForByte(t *testing.T) { func TestClineWrappersTranslateFreezeAndForwardPayloadByteForByte(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{}) fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture) installClineFixture(t, fixture)
if err := os.WriteFile(
fixture.guardPath,
[]byte("process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"),
0700,
); err != nil {
t.Fatalf("write frozen guard runtime: %v", err)
}
capturePath := filepath.Join(t.TempDir(), "captured-event.json") capturePath := filepath.Join(t.TempDir(), "captured-event.json")
captureJSON, err := json.Marshal(capturePath) captureJSON, err := json.Marshal(capturePath)
if err != nil { if err != nil {
@@ -123,7 +165,32 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{}) fixture := prepareClineFixture(t, clineFixtureOptions{})
fixture.config.BaseURL = server.URL fixture.config.BaseURL = server.URL
installClineFixture(t, fixture) installClineFixture(t, fixture)
payload := []byte(`{"hookName":"tool_result","taskId":"task-1","tool_result":{"name":"read_file","input":{"path":"README.md"},"output":"ok"}}`) payloadObject := map[string]any{
"clineVersion": "3.0.46",
"hookName": "tool_result",
"timestamp": "2026-07-19T12:00:00.000Z",
"taskId": "task-1",
"workspaceRoots": []any{
fixture.workspaceDir,
},
"userId": "user-1",
"agent_id": "cline-agent",
"parent_agent_id": nil,
"tool_result": map[string]any{
"id": "call-1", "name": "read_file",
"input": map[string]any{"path": "README.md"},
"output": "ok", "durationMs": float64(5),
},
"postToolUse": map[string]any{
"toolName": "read_file",
"parameters": map[string]any{"path": "README.md"},
"result": "ok", "success": true, "executionTimeMs": float64(5),
},
}
payload, err := json.Marshal(payloadObject)
if err != nil {
t.Fatalf("marshal Cline payload: %v", err)
}
result := runClineWrapper(t, fixture, fixture.auditWrapper, payload) result := runClineWrapper(t, fixture, fixture.auditWrapper, payload)
if result.exitCode != 0 { if result.exitCode != 0 {
t.Fatalf("audit wrapper result = %#v", result) t.Fatalf("audit wrapper result = %#v", result)
@@ -135,12 +202,7 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for Elydora operation") t.Fatal("timed out waiting for Elydora operation")
} }
wantPayload := map[string]any{ if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), payloadObject) {
"tool_name": "read_file",
"tool_input": map[string]any{"path": "README.md"},
"session_id": "task-1",
}
if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), wantPayload) {
t.Fatalf("operation payload = %#v", operation["payload"]) t.Fatalf("operation payload = %#v", operation["payload"])
} }
if !reflect.DeepEqual(requireClineTestObject(t, operation["action"]), map[string]any{"tool": "read_file"}) { if !reflect.DeepEqual(requireClineTestObject(t, operation["action"]), map[string]any{"tool": "read_file"}) {
@@ -152,10 +214,11 @@ func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
} }
func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) { func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) {
passing := prepareClineFixture(t, clineFixtureOptions{ passing := prepareClineFixture(t, clineFixtureOptions{})
GuardSource: clineString("process.stdin.resume();\n"),
})
installClineFixture(t, passing) installClineFixture(t, passing)
if err := os.WriteFile(passing.guardPath, []byte("process.stdin.resume();\n"), 0700); err != nil {
t.Fatalf("write passing guard runtime: %v", err)
}
passResult := runClineWrapper(t, passing, passing.guardWrapper, []byte(`{}`)) passResult := runClineWrapper(t, passing, passing.guardWrapper, []byte(`{}`))
if passResult.exitCode != 0 || passResult.stdout != "" { if passResult.exitCode != 0 || passResult.stdout != "" {
t.Fatalf("passing guard result = %#v", passResult) t.Fatalf("passing guard result = %#v", passResult)
+98 -19
View File
@@ -8,17 +8,20 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings"
"testing" "testing"
"time" "time"
) )
const clineTestAgentID = "agent-1" const (
clineTestAgentID = "agent-1"
clinePrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"
)
type clineFixtureOptions struct { type clineFixtureOptions struct {
ExistingAudit *string ExistingAudit *string
ExistingGuard *string ExistingGuard *string
GuardSource *string
SkipGuard bool
} }
type clineFixture struct { type clineFixture struct {
@@ -60,20 +63,11 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur
guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs") guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs")
auditWrapper := filepath.Join(hooksDir, "PostToolUse.mjs") auditWrapper := filepath.Join(hooksDir, "PostToolUse.mjs")
for _, directory := range []string{workspaceDir, agentDir} { for _, directory := range []string{workspaceDir} {
if err := os.MkdirAll(directory, 0755); err != nil { if err := os.MkdirAll(directory, 0755); err != nil {
t.Fatalf("create fixture directory %s: %v", directory, err) t.Fatalf("create fixture directory %s: %v", directory, err)
} }
} }
if !options.SkipGuard {
guardSource := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"
if options.GuardSource != nil {
guardSource = *options.GuardSource
}
if err := os.WriteFile(guardPath, []byte(guardSource), 0700); err != nil {
t.Fatalf("write guard runtime: %v", err)
}
}
writeOptionalClineTestFile(t, guardWrapper, options.ExistingGuard) writeOptionalClineTestFile(t, guardWrapper, options.ExistingGuard)
writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit) writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit)
@@ -97,8 +91,9 @@ func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixtur
AgentName: "cline", AgentName: "cline",
OrgID: "org-1", OrgID: "org-1",
AgentID: clineTestAgentID, AgentID: clineTestAgentID,
PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", PrivateKey: clinePrivateKey,
KID: "kid-1", KID: "kid-1",
Token: "token-1",
BaseURL: "https://api.elydora.test", BaseURL: "https://api.elydora.test",
GuardScriptPath: guardPath, GuardScriptPath: guardPath,
}, },
@@ -193,13 +188,97 @@ func requireClineTestObject(t *testing.T, value any) map[string]any {
func decodeClineControl(t *testing.T, stdout string) map[string]any { func decodeClineControl(t *testing.T, stdout string) map[string]any {
t.Helper() t.Helper()
const marker = "HOOK_CONTROL\t"
if len(stdout) < len(marker) || stdout[:len(marker)] != marker {
t.Fatalf("Cline control output = %q", stdout)
}
var control map[string]any var control map[string]any
if err := json.Unmarshal([]byte(stdout[len(marker):]), &control); err != nil { if err := json.Unmarshal([]byte(stdout), &control); err != nil {
t.Fatalf("decode Cline control output: %v", err) t.Fatalf("decode Cline control output: %v", err)
} }
return control return control
} }
func writeClineTestFile(
t *testing.T,
path string,
contents []byte,
mode os.FileMode,
) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create parent directory for %s: %v", path, err)
}
if err := os.WriteFile(path, contents, mode); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func writeClineTestObject(t *testing.T, path string, value map[string]any) {
t.Helper()
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("marshal Cline test object: %v", err)
}
writeClineTestFile(t, path, append(encoded, '\n'), 0600)
}
func readClineTestObject(t *testing.T, path string) map[string]any {
t.Helper()
var value map[string]any
if err := json.Unmarshal([]byte(readClineTestFile(t, path)), &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
func assertNoClineRuntimeWrites(t *testing.T, fixture *clineFixture) {
t.Helper()
for _, path := range []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("managed file exists at %s: %v", path, err)
}
}
}
func assertNoClineTransactionArtifacts(t *testing.T, root string) {
t.Helper()
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
return
}
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
name := entry.Name()
if strings.HasSuffix(name, ".tmp") || strings.HasSuffix(name, ".rollback") {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk Cline fixture: %v", err)
}
}
func clineSymlinkOrSkip(t *testing.T, target, link string) {
t.Helper()
if runtime.GOOS == "windows" {
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
return
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
}
func sameClineTestPath(left, right string) bool {
leftPath, leftErr := filepath.Abs(left)
rightPath, rightErr := filepath.Abs(right)
return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath)
}
@@ -0,0 +1,368 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func prepareClineTestChanges(
t *testing.T,
fixture *clineFixture,
) ([]*fileChange, *clineRuntimePaths) {
t.Helper()
paths, guard, audit, err := readClineHookPair()
if err != nil {
t.Fatalf("read Cline hook pair: %v", err)
}
runtimePaths, err := preflightClineInstallation(fixture.config, paths)
if err != nil {
t.Fatalf("preflight Cline installation: %v", err)
}
changes, err := prepareClineInstallationChanges(
fixture.config,
runtimePaths,
guard,
audit,
)
if err != nil {
t.Fatalf("prepare Cline installation: %v", err)
}
return changes, runtimePaths
}
func snapshotClineFiles(t *testing.T, paths ...string) map[string]string {
t.Helper()
result := make(map[string]string, len(paths))
for _, path := range paths {
result[path] = readClineTestFile(t, path)
}
return result
}
func requireClineSnapshot(t *testing.T, snapshot map[string]string) {
t.Helper()
for path, expected := range snapshot {
if actual := readClineTestFile(t, path); actual != expected {
t.Fatalf("snapshot changed at %s", path)
}
}
}
func TestClineInstallRollsBackAllSixFiles(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
failed := false
fixture.plugin.rename = func(source, destination string) error {
if !failed && sameClineTestPath(destination, fixture.auditWrapper) &&
strings.HasSuffix(source, ".tmp") {
failed = true
return errors.New("injected Cline audit hook failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Cline audit hook failure") {
t.Fatalf("install error = %v", err)
}
if !failed {
t.Fatal("audit hook failure was not injected")
}
assertNoClineRuntimeWrites(t, fixture)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestClineInstallRestoresAllSixExistingFilesAfterCommitFailure(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
writeClineTestObject(t, fixture.runtimeConfig, map[string]any{
"org_id": "old-org", "agent_id": clineTestAgentID, "kid": "old-kid",
"base_url": "https://old.elydora.test", "agent_name": clineAgentKey,
})
for path, source := range map[string]string{
fixture.guardPath: "stale guard\n",
fixture.privateKey: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc",
fixture.hookPath: "stale audit\n",
fixture.guardWrapper: readClineTestFile(t, fixture.guardWrapper) + "// stale\n",
fixture.auditWrapper: readClineTestFile(t, fixture.auditWrapper) + "// stale\n",
} {
writeClineTestFile(t, path, []byte(source), 0600)
}
paths := []string{
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
}
before := snapshotClineFiles(t, paths...)
failed := false
fixture.plugin.rename = func(source, destination string) error {
if !failed && sameClineTestPath(destination, fixture.auditWrapper) &&
strings.HasSuffix(source, ".tmp") {
failed = true
return errors.New("injected existing Cline audit hook failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected existing Cline audit hook failure") {
t.Fatalf("install error = %v", err)
}
requireClineSnapshot(t, before)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestPreparedClineInstallRejectsStaleHookBeforeStaging(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
paths, guard, audit, err := readClineHookPair()
if err != nil {
t.Fatalf("read Cline hook pair: %v", err)
}
runtimePaths, err := preflightClineInstallation(fixture.config, paths)
if err != nil {
t.Fatalf("preflight Cline installation: %v", err)
}
concurrent := []byte("// concurrent user hook\n")
writeClineTestFile(t, fixture.auditWrapper, concurrent, 0600)
_, err = prepareClineInstallationChanges(
fixture.config,
runtimePaths,
guard,
audit,
)
if err == nil || !strings.Contains(err.Error(), "changed before update") {
t.Fatalf("stale hook error = %v", err)
}
if readClineTestFile(t, fixture.auditWrapper) != string(concurrent) {
t.Fatal("concurrent hook changed")
}
for _, path := range []string{
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
} {
requireMissingClineTestFile(t, path)
}
}
func TestPreparedClineInstallRejectsConcurrentHookChange(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
changes, paths := prepareClineTestChanges(t, fixture)
concurrent := []byte("// concurrent user hook\n")
writeClineTestFile(t, fixture.auditWrapper, concurrent, 0600)
err := writeClineChanges(changes, "Install Cline hooks", nil, paths)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent hook error = %v", err)
}
if readClineTestFile(t, fixture.auditWrapper) != string(concurrent) {
t.Fatal("concurrent hook changed")
}
for _, path := range []string{
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
} {
requireMissingClineTestFile(t, path)
}
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestClineInstallDetectsConcurrentHookIdentityReplacement(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
tampered := readClineTestFile(t, fixture.auditWrapper) + "// stale\n"
writeClineTestFile(t, fixture.auditWrapper, []byte(tampered), 0700)
fixture.config.OrgID = "org-updated"
before := snapshotClineFiles(
t,
fixture.guardPath,
fixture.runtimeConfig,
fixture.privateKey,
fixture.hookPath,
fixture.guardWrapper,
fixture.auditWrapper,
)
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if !mutated && strings.HasSuffix(source, ".tmp") {
mutated = true
external := fixture.auditWrapper + ".external"
if err := os.Rename(fixture.auditWrapper, external); err != nil {
return err
}
if err := os.WriteFile(fixture.auditWrapper, []byte(tampered), 0700); err != nil {
return err
}
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent identity error = %v", err)
}
requireClineSnapshot(t, before)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestClineOrphanRuntimeArtifactsFailBeforeHookWrites(t *testing.T) {
for _, name := range []string{
"private.key", clineGuardScript, clineAuditScript,
"chain-state.json", "status-cache.json", "error.log",
} {
t.Run(name, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
artifact := filepath.Join(fixture.agentDir, name)
writeClineTestFile(t, artifact, []byte("orphan\n"), 0600)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") {
t.Fatalf("orphan runtime error = %v", err)
}
if readClineTestFile(t, artifact) != "orphan\n" {
t.Fatal("orphan artifact changed")
}
requireMissingClineTestFile(t, fixture.guardWrapper)
requireMissingClineTestFile(t, fixture.auditWrapper)
})
}
}
func TestClineMismatchedRuntimeIdentityFailsBeforeWrites(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
writeClineTestObject(t, fixture.runtimeConfig, map[string]any{
"agent_id": "another-agent", "agent_name": clineAgentKey,
})
original := readClineTestFile(t, fixture.runtimeConfig)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("identity error = %v", err)
}
if readClineTestFile(t, fixture.runtimeConfig) != original {
t.Fatal("runtime config changed")
}
requireMissingClineTestFile(t, fixture.guardWrapper)
}
func TestClineLinkedDirectoriesAndFilesAreRejected(t *testing.T) {
for _, kind := range []string{
"configuration", "hooks", "runtime", "agent-directory", "runtime-config", "hook",
} {
t.Run(kind, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
target := filepath.Join(t.TempDir(), kind+"-target")
switch kind {
case "configuration":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.clineDir)
case "hooks":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target: %v", err)
}
if err := os.MkdirAll(fixture.clineDir, 0700); err != nil {
t.Fatalf("create Cline directory: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.hooksDir)
case "runtime":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target: %v", err)
}
clineSymlinkOrSkip(t, target, filepath.Dir(fixture.agentDir))
case "agent-directory":
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create target: %v", err)
}
if err := os.MkdirAll(filepath.Dir(fixture.agentDir), 0700); err != nil {
t.Fatalf("create runtime root: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.agentDir)
case "runtime-config":
writeClineTestFile(t, target, []byte(`{"agent_id":"agent-1","agent_name":"cline"}`), 0600)
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent directory: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.runtimeConfig)
case "hook":
installClineFixture(t, fixture)
writeClineTestFile(t, target, []byte("external hook\n"), 0600)
if err := os.Remove(fixture.guardWrapper); err != nil {
t.Fatalf("remove guard wrapper: %v", err)
}
clineSymlinkOrSkip(t, target, fixture.guardWrapper)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical") {
t.Fatalf("linked %s error = %v", kind, err)
}
})
}
}
func TestClineInstallValidatesInputsBeforeWrites(t *testing.T) {
tests := []struct{ name, field, value, want string }{
{"agent-name", "agent_name", "codex", "requires agent name cline"},
{"agent-id", "agent_id", "../escape", "invalid agent ID"},
{"organization", "org_id", " ", "organization ID is required"},
{"key-id", "kid", " ", "key ID is required"},
{"private-key", "private_key", "invalid", "canonical 32-byte"},
{"token", "token", " ", "non-whitespace"},
{"base-url", "base_url", "https://api.elydora.com?q=1", "query parameters"},
{"guard-path", "guard_script_path", "outside", "managed agent directory"},
{"audit-path", "hook_script", "outside", "managed agent directory"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
switch testCase.field {
case "agent_name":
fixture.config.AgentName = testCase.value
case "agent_id":
fixture.config.AgentID = testCase.value
case "org_id":
fixture.config.OrgID = testCase.value
case "kid":
fixture.config.KID = testCase.value
case "private_key":
fixture.config.PrivateKey = testCase.value
case "token":
fixture.config.Token = testCase.value
case "base_url":
fixture.config.BaseURL = testCase.value
case "guard_script_path":
fixture.config.GuardScriptPath = testCase.value
case "hook_script":
fixture.config.HookScript = testCase.value
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("validation error = %v, want %q", err, testCase.want)
}
assertNoClineRuntimeWrites(t, fixture)
})
}
}
func TestClineUninstallRestoresBothHooksWhenSecondRemovalFails(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
before := snapshotClineFiles(t, fixture.guardWrapper, fixture.auditWrapper)
calls := 0
fixture.plugin.rename = func(source, destination string) error {
calls++
if calls == 2 {
return errors.New("injected Cline uninstall failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Uninstall(clineTestAgentID)
if err == nil || !strings.Contains(err.Error(), "injected Cline uninstall failure") {
t.Fatalf("uninstall error = %v", err)
}
requireClineSnapshot(t, before)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}
func TestClineInstallLeavesNoTransactionArtifacts(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
assertNoClineTransactionArtifacts(t, fixture.homeDir)
}