fix: make Codex hooks transactional

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 03:22:58 -04:00
1 parent 415a884efe
commit f0e4694da2
17 files changed
+2550 -506

No files matched your search

+2
View File
@@ -21,6 +21,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
- Forward official hook JSON from STDIN without reshaping provider fields.
- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard.
- Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist.
- Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default and canonicalize configured existing directories. Preserve additive TOML, project, plugin, and managed sources. Register exact `PreToolUse` and `PostToolUse` match-all command groups, retain the native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, and commit user hooks plus all four runtime artifacts through one rollback-capable transaction. Require `/hooks` approval for both definition hashes.
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
- Select Kimi Code and legacy `kimi-cli` contracts from runtime evidence. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; create no cross-runtime migration marker.
@@ -46,6 +47,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
- Accept CLI install credentials through terminal-echo-disabled input or physical owner-only credential files. Keep credentials out of process arguments, reject legacy secret options with redacted errors, and require one UTF-8 line of at most 64 KiB.
- Commit runtime config, private key, and audit script through one rollback-capable transaction. Persist the signing key once with mode `0600`; generated runtimes validate physical identity, size, and Unix permissions before reading config or key material.
- Write the guard runtime through a flushed same-directory temporary file followed by atomic replacement. Surface permission, close, synchronization, commit, cleanup, and recovery failures.
- Read status cache, chain state, and error logs through physical descriptors with identity checks. Write cache and validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error.
## Verification
+1 -1
View File
@@ -79,7 +79,7 @@ elydora install \
Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token without terminal echo. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. Installation commits `config.json`, `private.key`, and `hook.js` as one rollback-capable runtime transaction, and the generated scripts validate those protected files at execution time.
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
Cursor installation writes native global `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json`. Elydora preserves unrelated user hooks, leaves project and enterprise sources unchanged, emits valid JSON hook responses, fails closed when enforcement or audit execution cannot complete, and commits its 10-second hook contract with all managed runtime files as one rollback-capable transaction.
+83 -118
View File
@@ -1,162 +1,127 @@
package plugins
import "fmt"
import (
"fmt"
"path/filepath"
)
// CodexPlugin manages OpenAI Codex user lifecycle hooks.
type CodexPlugin struct{}
// CodexPlugin manages OpenAI Codex global user hooks.
type CodexPlugin struct {
rename renameFunc
}
// ManagesGuardRuntime reports that Codex commits its provider guard with the
// audit runtime and user hook document.
func (p *CodexPlugin) ManagesGuardRuntime() bool {
return true
}
// PreflightInstall validates every existing source before the CLI creates
// runtime state.
func (p *CodexPlugin) PreflightInstall(config InstallConfig) error {
document, err := readCodexDocument()
if err != nil {
return err
}
_, _, err = preflightCodexInstallation(config, document.filePath)
return err
}
func (p *CodexPlugin) Install(config InstallConfig) error {
if config.AgentID == "" {
return fmt.Errorf("agent ID is required")
}
configPath, err := codexConfigPath()
document, err := readCodexDocument()
if err != nil {
return err
}
settings, exists, err := readHookJSONObject(configPath, "Codex hooks config")
paths, nodePath, err := preflightCodexInstallation(config, document.filePath)
if err != nil {
return err
}
if !exists {
settings = map[string]any{"description": codexOwnedDescription}
hooks := removeManagedCodexHooks(document.hooks, "")
hooks["PreToolUse"] = append(
hooks["PreToolUse"],
codexMatcherGroup(codexHandler(
nodePath,
paths.guardPath,
codexGuardStatusMessage,
)),
)
hooks["PostToolUse"] = append(
hooks["PostToolUse"],
codexMatcherGroup(codexHandler(
nodePath,
paths.auditPath,
codexAuditStatusMessage,
)),
)
rendered, err := renderCodexDocument(document, hooks)
if err != nil {
return fmt.Errorf("render Codex user hooks: %w", err)
}
hooks, err := codexHooksObject(settings)
changes, err := prepareCodexInstallationChanges(config, paths, rendered)
if err != nil {
return err
}
preGroups, err := codexEventGroups(hooks, "PreToolUse")
if err != nil {
if err := writeCodexChanges(
changes,
"Install Codex hooks",
p.rename,
paths.runtimeRoot,
paths.agentDirectory,
filepath.Dir(document.filePath),
); err != nil {
return err
}
postGroups, err := codexEventGroups(hooks, "PostToolUse")
if err != nil {
return err
}
preGroups, _, err = withoutCodexHandlers(preGroups, "")
if err != nil {
return err
}
postGroups, _, err = withoutCodexHandlers(postGroups, "")
if err != nil {
return err
}
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
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return err
}
hooks["PreToolUse"] = append(preGroups, codexMatcherGroup(
codexHandler(nodePath, config.GuardScriptPath, codexGuardStatusMessage),
))
hooks["PostToolUse"] = append(postGroups, codexMatcherGroup(
codexHandler(nodePath, hookPath, codexAuditStatusMessage),
))
next := cloneCodexObject(settings)
next["hooks"] = hooks
runtimeConfig := config
runtimeConfig.AgentName = codexAgentKey
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
if err := writeHookJSONObjectAtomic(configPath, next); err != nil {
return fmt.Errorf("write Codex hooks config: %w", err)
}
fmt.Println("Codex: run /hooks to review and trust the Elydora hooks.")
fmt.Printf(" Codex hooks: %s\n", document.filePath)
fmt.Println(" Codex trust: run /hooks and approve both Elydora command hooks.")
return nil
}
func (p *CodexPlugin) Uninstall(agentID string) error {
configPath, err := codexConfigPath()
document, err := readCodexDocument()
if err != nil {
return err
}
settings, exists, err := readHookJSONObject(configPath, "Codex hooks config")
if err != nil || !exists {
return err
hooks := removeManagedCodexHooks(document.hooks, agentID)
rendered, err := renderCodexDocument(document, hooks)
if err != nil {
return fmt.Errorf("render Codex user hooks: %w", err)
}
hooks, err := codexHooksObject(settings)
change, err := prepareRenderedCodexChange(rendered)
if err != nil {
return err
}
preGroups, err := codexEventGroups(hooks, "PreToolUse")
if err != nil {
return err
}
postGroups, err := codexEventGroups(hooks, "PostToolUse")
if err != nil {
return err
}
filteredPre, preChanged, err := withoutCodexHandlers(preGroups, agentID)
if err != nil {
return err
}
filteredPost, postChanged, err := withoutCodexHandlers(postGroups, agentID)
if err != nil {
return err
}
if !preChanged && !postChanged {
return nil
}
hooks["PreToolUse"] = filteredPre
hooks["PostToolUse"] = filteredPost
if codexSettingsOwned(settings, hooks) {
return removeHookFile(configPath, "Codex hooks config")
}
next := cloneCodexObject(settings)
next["hooks"] = hooks
return writeHookJSONObjectAtomic(configPath, next)
return writeCodexChanges(
[]*fileChange{change},
"Uninstall Codex hooks",
p.rename,
"",
"",
filepath.Dir(document.filePath),
)
}
func (p *CodexPlugin) Status() (PluginStatus, error) {
configPath, err := codexConfigPath()
configPath, pathErr := codexConfigPath()
entry := SupportedAgents[codexAgentKey]
status := PluginStatus{
AgentName: codexAgentKey,
DisplayName: "OpenAI Codex",
ConfigPath: configPath,
AgentName: codexAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
}
if pathErr != nil {
return status, pathErr
}
document, err := readCodexDocument()
if err != nil {
return status, err
}
settings, exists, err := readHookJSONObject(configPath, "Codex hooks config")
if err != nil || !exists {
return status, err
}
hooks, err := codexHooksObject(settings)
contracts, err := codexRuntimeContracts(document.hooks)
if err != nil {
return status, err
}
guard, err := findCodexHandler(hooks, "PreToolUse", codexGuardStatusMessage)
if err != nil {
return status, err
}
audit, err := findCodexHandler(hooks, "PostToolUse", codexAuditStatusMessage)
if err != nil {
return status, err
}
if guard == nil || audit == nil {
status.HookConfigured = len(contracts) > 0
if !status.HookConfigured {
return status, nil
}
status.HookConfigured = true
status.HookScriptExists, err = codexRuntimeScriptsExist(guard, audit)
status.HookScriptExists, err = codexRuntimeFilesExist(contracts)
if err != nil {
return status, err
}
+230
View File
@@ -0,0 +1,230 @@
package plugins
import (
"encoding/base64"
"os"
"path/filepath"
"runtime"
"strings"
"unicode/utf16"
)
func quoteCodexPowerShell(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func codexPowerShellPath() string {
root := "C:\\Windows"
if runtime.GOOS == "windows" {
configured := os.Getenv("SystemRoot")
if filepath.IsAbs(configured) && !strings.ContainsAny(configured, "\"%\r\n") {
root = configured
}
}
return strings.TrimRight(root, `\/`) +
`\System32\WindowsPowerShell\v1.0\powershell.exe`
}
func encodeCodexPowerShell(source string) string {
runes := utf16.Encode([]rune(source))
raw := make([]byte, len(runes)*2)
for index, value := range runes {
raw[index*2] = byte(value)
raw[index*2+1] = byte(value >> 8)
}
return base64.StdEncoding.EncodeToString(raw)
}
func codexWindowsCommand(runtimePath, scriptPath string) string {
source := "& " + quoteCodexPowerShell(runtimePath) + " " +
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE"
return `"` + codexPowerShellPath() +
`" -NoLogo -NoProfile -NonInteractive -EncodedCommand ` +
encodeCodexPowerShell(source)
}
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
return map[string]any{
"type": "command",
"command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath),
"commandWindows": codexWindowsCommand(runtimePath, scriptPath),
"timeout": codexHookTimeout,
"statusMessage": statusMessage,
}
}
func codexMatcherGroup(handler map[string]any) map[string]any {
return map[string]any{"matcher": "*", "hooks": []any{handler}}
}
func readCodexPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], codexPOSIXApostrophe) {
value.WriteByte('\'')
index += len(codexPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func parseCodexPOSIXCommand(value any) (string, string, bool) {
command, ok := value.(string)
if !ok {
return "", "", false
}
runtimePath, next, ok := readCodexPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
scriptPath, end, ok := readCodexPOSIXArgument(command, next+1)
return runtimePath, scriptPath, ok && end == len(command)
}
func readCodexPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func parseCodexPowerShellSource(source string) (string, string, bool) {
if !strings.HasPrefix(source, "& ") {
return "", "", false
}
runtimePath, next, ok := readCodexPowerShellArgument(source, 2)
if !ok || next >= len(source) || source[next] != ' ' {
return "", "", false
}
scriptPath, end, ok := readCodexPowerShellArgument(source, next+1)
return runtimePath, scriptPath, ok && source[end:] == "; exit $LASTEXITCODE"
}
func windowsPathBase(value string) string {
index := strings.LastIndexAny(value, `\/`)
return value[index+1:]
}
func isAbsoluteWindowsPath(value string) bool {
return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') ||
(value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' &&
(value[2] == '\\' || value[2] == '/') || strings.HasPrefix(value, `\\`)
}
func decodeCodexPowerShell(encoded string) (string, bool) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded || len(raw)%2 != 0 {
return "", false
}
units := make([]uint16, len(raw)/2)
for index := range units {
units[index] = uint16(raw[index*2]) | uint16(raw[index*2+1])<<8
}
return string(utf16.Decode(units)), true
}
func parseCodexWindowsCommand(value any) (string, string, bool) {
command, ok := value.(string)
if !ok || !strings.HasPrefix(command, `"`) {
return "", "", false
}
closing := strings.IndexByte(command[1:], '"')
if closing < 0 {
return "", "", false
}
closing++
powerShell := command[1:closing]
prefix := `" -NoLogo -NoProfile -NonInteractive -EncodedCommand `
if !isAbsoluteWindowsPath(powerShell) ||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") ||
!strings.HasPrefix(command[closing:], prefix) {
return "", "", false
}
encoded := command[closing+len(prefix):]
source, ok := decodeCodexPowerShell(encoded)
if !ok {
return "", "", false
}
return parseCodexPowerShellSource(source)
}
func isCodexNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func exactCodexHandlerKeys(handler map[string]any) bool {
if len(handler) != 5 {
return false
}
for _, key := range []string{"type", "command", "commandWindows", "timeout", "statusMessage"} {
if _, exists := handler[key]; !exists {
return false
}
}
return true
}
func codexManagedScriptPath(handler map[string]any, status string) (string, bool) {
if !exactCodexHandlerKeys(handler) || handler["type"] != "command" ||
handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status {
return "", false
}
posixRuntime, posixScript, posixOK := parseCodexPOSIXCommand(handler["command"])
windowsRuntime, windowsScript, windowsOK := parseCodexWindowsCommand(handler["commandWindows"])
if !windowsOK && posixOK {
legacy, ok := handler["commandWindows"].(string)
expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript)
if ok && legacy == expected {
windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true
}
}
if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) ||
!filepath.IsAbs(posixScript) || !isCodexNodeExecutable(posixRuntime) ||
!isCodexNodeExecutable(windowsRuntime) ||
!sameCodexPath(posixRuntime, windowsRuntime) ||
!sameCodexPath(posixScript, windowsScript) {
return "", false
}
return posixScript, true
}
func codexManagedAgentID(
handler map[string]any,
scriptName string,
status string,
) (string, bool) {
scriptPath, managed := codexManagedScriptPath(handler, status)
if !managed || filepath.Base(scriptPath) != scriptName {
return "", false
}
agentDirectory := filepath.Dir(scriptPath)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil || !sameCodexPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false
}
agentID := filepath.Base(agentDirectory)
return agentID, agentID != "" && agentID != "." && agentID != ".."
}
+265 -184
View File
@@ -1,20 +1,51 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
)
const (
codexAgentKey = "codex"
codexConfigFile = "hooks.json"
codexGuardScript = "guard.js"
codexAuditScript = "hook.js"
codexHookTimeout = float64(10)
codexOwnedDescription = "Elydora audit and freeze enforcement"
codexGuardStatusMessage = "Checking Elydora agent state"
codexAuditStatusMessage = "Recording Elydora tool use"
codexPOSIXApostrophe = `'"'"'`
)
type codexHooks map[string][]map[string]any
type codexDocument struct {
exists bool
filePath string
root map[string]any
hooks codexHooks
raw []byte
}
type codexRenderedDocument struct {
document *codexDocument
changed bool
next []byte
remove bool
}
type codexRuntimeContract struct {
agentID string
guardPath string
auditPath string
}
func cloneCodexObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
@@ -23,230 +54,280 @@ func cloneCodexObject(value map[string]any) map[string]any {
return clone
}
func codexHooksObject(settings map[string]any) (map[string]any, error) {
value, exists := settings["hooks"]
if !exists {
return map[string]any{}, nil
func cloneCodexHooks(source codexHooks) codexHooks {
clone := make(codexHooks, len(source))
for event, groups := range source {
clone[event] = append([]map[string]any(nil), groups...)
}
hooks, ok := value.(map[string]any)
if !ok || hooks == nil {
return nil, fmt.Errorf(`Codex hooks config field "hooks" must be an object`)
}
return cloneCodexObject(hooks), nil
return clone
}
func codexEventGroups(hooks map[string]any, event string) ([]map[string]any, error) {
value, exists := hooks[event]
if !exists {
return []map[string]any{}, nil
func sameCodexPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
groups, ok := value.([]any)
if !ok {
return nil, fmt.Errorf(`Codex hooks config field "hooks.%s" must be an array of objects`, event)
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
objects := make([]map[string]any, 0, len(groups))
for _, group := range groups {
object, ok := group.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`Codex hooks config field "hooks.%s" must be an array of objects`, event)
}
objects = append(objects, object)
}
return objects, nil
}
func codexGroupHandlers(group map[string]any) ([]map[string]any, error) {
value, exists := group["hooks"]
if !exists {
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
}
handlers, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
}
objects := make([]map[string]any, 0, len(handlers))
for _, handler := range handlers {
object, ok := handler.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
}
objects = append(objects, object)
}
return objects, nil
}
func normalizeCodexPath(value string) string {
normalized := filepath.ToSlash(value)
if runtime.GOOS == "windows" {
return strings.ToLower(normalized)
return strings.EqualFold(left, right)
}
return normalized
return left == right
}
func isManagedCodexCommand(command any, scriptName, agentID string) bool {
text, ok := command.(string)
func sameCodexAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func readCodexHooks(value any, label string) (codexHooks, error) {
object, ok := value.(map[string]any)
if !ok {
return false
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
}
normalized := normalizeCodexPath(text)
if !strings.Contains(normalized, "/.elydora/") {
return false
}
suffix := "/" + normalizeCodexPath(scriptName)
if agentID == "" {
return codexCommandEndsWith(normalized, suffix)
}
expected := normalizeCodexPath(filepath.Join(".elydora", agentID, scriptName))
return codexCommandEndsWith(normalized, "/"+expected)
}
func codexCommandEndsWith(command, scriptPath string) bool {
trimmed := strings.TrimSpace(command)
return strings.HasSuffix(trimmed, scriptPath) ||
strings.HasSuffix(trimmed, scriptPath+`'`) ||
strings.HasSuffix(trimmed, scriptPath+`"`)
}
func isElydoraCodexHandler(handler map[string]any, agentID string) bool {
var scriptName string
switch handler["statusMessage"] {
case codexGuardStatusMessage:
scriptName = "guard.js"
case codexAuditStatusMessage:
scriptName = "hook.js"
default:
return false
}
return isManagedCodexCommand(handler["command"], scriptName, agentID) ||
isManagedCodexCommand(handler["commandWindows"], scriptName, agentID)
}
func withoutCodexHandlers(groups []map[string]any, agentID string) ([]map[string]any, bool, error) {
filteredGroups := make([]map[string]any, 0, len(groups))
changed := false
for _, group := range groups {
handlers, err := codexGroupHandlers(group)
if err != nil {
return nil, false, err
hooks := make(codexHooks, len(object))
for event, candidate := range object {
values, ok := candidate.([]any)
if !ok {
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
}
filteredHandlers := make([]any, 0, len(handlers))
removed := false
for _, handler := range handlers {
if isElydoraCodexHandler(handler, agentID) {
changed = true
removed = true
continue
groups := make([]map[string]any, 0, len(values))
for groupIndex, value := range values {
group, ok := value.(map[string]any)
if !ok || group == nil {
return nil, fmt.Errorf(
`%s matcher group hooks.%s[%d] must be an object`,
label, event, groupIndex,
)
}
filteredHandlers = append(filteredHandlers, handler)
handlerValues, ok := group["hooks"].([]any)
if !ok {
return nil, fmt.Errorf(
`%s matcher group hooks.%s[%d] must contain a hooks array`,
label, event, groupIndex,
)
}
for handlerIndex, handler := range handlerValues {
if object, ok := handler.(map[string]any); !ok || object == nil {
return nil, fmt.Errorf(
`%s handler hooks.%s[%d].hooks[%d] must be an object`,
label, event, groupIndex, handlerIndex,
)
}
}
groups = append(groups, group)
}
if len(filteredHandlers) == 0 && removed {
continue
}
nextGroup := cloneCodexObject(group)
nextGroup["hooks"] = filteredHandlers
filteredGroups = append(filteredGroups, nextGroup)
hooks[event] = groups
}
return filteredGroups, changed, nil
return hooks, nil
}
func findCodexHandler(hooks map[string]any, event, status string) (map[string]any, error) {
groups, err := codexEventGroups(hooks, event)
func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) {
if !json.Valid(source) {
var value any
if err := json.Unmarshal(source, &value); err != nil {
return nil, fmt.Errorf("parse %s: %w", label, err)
}
}
return decodeJSONCObject(source, label, false)
}
func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) {
label := fmt.Sprintf("Codex user hooks at %s", filePath)
root, err := decodeStrictJSONObject(raw, label)
if err != nil {
return nil, err
}
for _, group := range groups {
handlers, err := codexGroupHandlers(group)
hooks := codexHooks{}
if value, exists := root["hooks"]; exists {
hooks, err = readCodexHooks(value, label)
if err != nil {
return nil, err
}
for _, handler := range handlers {
if handler["statusMessage"] == status && isElydoraCodexHandler(handler, "") {
return handler, nil
}
}
}
return nil, nil
return &codexDocument{
exists: true, filePath: filePath, root: root,
hooks: hooks, raw: append([]byte(nil), raw...),
}, nil
}
func codexSettingsOwned(settings, hooks map[string]any) bool {
for key := range settings {
func createCodexDocument(filePath string) *codexDocument {
return &codexDocument{
filePath: filePath,
root: map[string]any{"description": codexOwnedDescription},
hooks: codexHooks{},
}
}
func exactCodexMatcherGroup(group map[string]any) bool {
return len(group) == 2 && group["matcher"] == "*"
}
func removeCodexGroups(
groups []map[string]any,
scriptName string,
status string,
agentID string,
) []map[string]any {
result := make([]map[string]any, 0, len(groups))
for _, group := range groups {
handlers := group["hooks"].([]any)
kept := make([]any, 0, len(handlers))
for _, value := range handlers {
handler := value.(map[string]any)
managedID, managed := codexManagedAgentID(handler, scriptName, status)
if managed && (agentID == "" || sameCodexAgentID(managedID, agentID)) {
continue
}
kept = append(kept, handler)
}
if len(kept) > 0 || !exactCodexMatcherGroup(group) {
next := cloneCodexObject(group)
next["hooks"] = kept
result = append(result, next)
}
}
return result
}
func removeManagedCodexHooks(hooks codexHooks, agentID string) codexHooks {
next := cloneCodexHooks(hooks)
for _, contract := range []struct{ event, script, status string }{
{"PreToolUse", codexGuardScript, codexGuardStatusMessage},
{"PostToolUse", codexAuditScript, codexAuditStatusMessage},
} {
groups := removeCodexGroups(
next[contract.event], contract.script, contract.status, agentID,
)
if len(groups) == 0 {
delete(next, contract.event)
} else {
next[contract.event] = groups
}
}
return next
}
func entirelyManagedCodexDocument(document *codexDocument) bool {
if !document.exists || document.root["description"] != codexOwnedDescription ||
len(document.hooks) == 0 {
return false
}
for key := range document.root {
if key != "description" && key != "hooks" {
return false
}
}
if settings["description"] != codexOwnedDescription {
return false
}
for event, value := range hooks {
if event != "PreToolUse" && event != "PostToolUse" {
handlerCount := 0
for event, groups := range document.hooks {
contract := map[string][2]string{
"PreToolUse": {codexGuardScript, codexGuardStatusMessage},
"PostToolUse": {codexAuditScript, codexAuditStatusMessage},
}[event]
if contract[0] == "" || len(groups) == 0 {
return false
}
groups, ok := value.([]map[string]any)
if !ok || len(groups) != 0 {
return false
for _, group := range groups {
handlers := group["hooks"].([]any)
if !exactCodexMatcherGroup(group) || len(handlers) == 0 {
return false
}
for _, value := range handlers {
if _, managed := codexManagedAgentID(
value.(map[string]any), contract[0], contract[1],
); !managed {
return false
}
handlerCount++
}
}
}
return true
return handlerCount > 0
}
func codexCommandReferences(handler map[string]any, scriptPath string) bool {
expected := normalizeCodexPath(scriptPath)
for _, key := range []string{"command", "commandWindows"} {
command, ok := handler[key].(string)
if ok && codexCommandEndsWith(normalizeCodexPath(command), expected) {
return true
}
func renderCodexDocument(
document *codexDocument,
hooks codexHooks,
) (*codexRenderedDocument, error) {
if !document.exists && len(hooks) == 0 {
return &codexRenderedDocument{document: document}, nil
}
return false
if document.exists && reflect.DeepEqual(hooks, document.hooks) {
return &codexRenderedDocument{document: document}, nil
}
if len(hooks) == 0 && entirelyManagedCodexDocument(document) {
return &codexRenderedDocument{document: document, changed: true, remove: true}, nil
}
root := cloneCodexObject(document.root)
if len(hooks) == 0 {
delete(root, "hooks")
} else {
root["hooks"] = hooks
}
next, err := json.MarshalIndent(root, "", " ")
if err != nil {
return nil, err
}
next = append(next, '\n')
return &codexRenderedDocument{
document: document,
changed: !document.exists || !bytes.Equal(next, document.raw),
next: next,
}, nil
}
func codexRuntimeScriptsExist(guard, audit map[string]any) (bool, error) {
home, err := os.UserHomeDir()
if err != nil {
return false, fmt.Errorf("resolve home directory: %w", err)
func managedCodexIDs(
groups []map[string]any,
scriptName string,
status string,
) map[string]string {
result := map[string]string{}
for _, group := range groups {
if !exactCodexMatcherGroup(group) {
continue
}
for _, value := range group["hooks"].([]any) {
agentID, managed := codexManagedAgentID(value.(map[string]any), scriptName, status)
if !managed {
continue
}
key := agentID
if runtime.GOOS == "windows" {
key = strings.ToLower(agentID)
}
result[key] = agentID
}
}
root := filepath.Join(home, ".elydora")
entries, err := os.ReadDir(root)
return result
}
func codexRuntimeContracts(hooks codexHooks) ([]codexRuntimeContract, error) {
guards := managedCodexIDs(hooks["PreToolUse"], codexGuardScript, codexGuardStatusMessage)
audits := managedCodexIDs(hooks["PostToolUse"], codexAuditScript, codexAuditStatusMessage)
keys := make([]string, 0, len(guards))
for key := range guards {
if _, exists := audits[key]; exists {
keys = append(keys, key)
}
}
sort.Strings(keys)
root, err := AgentRuntimeRoot()
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("read Elydora runtime directory at %s: %w", root, err)
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
agentDir := filepath.Join(root, entry.Name())
guardPath := filepath.Join(agentDir, "guard.js")
hookPath := filepath.Join(agentDir, "hook.js")
if !codexCommandReferences(guard, guardPath) || !codexCommandReferences(audit, hookPath) {
continue
}
configPath := filepath.Join(agentDir, "config.json")
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
if err != nil {
return false, err
}
if !exists {
continue
}
agentName, ok := config["agent_name"].(string)
if !ok {
return false, fmt.Errorf(`Elydora runtime config at %s field "agent_name" must be a string`, configPath)
}
if agentName != codexAgentKey {
continue
}
guardExists, err := regularFileExists(guardPath, "Elydora guard runtime")
if err != nil {
return false, err
}
hookExists, err := regularFileExists(hookPath, "Elydora audit runtime")
if err != nil {
return false, err
}
return guardExists && hookExists, nil
contracts := make([]codexRuntimeContract, 0, len(keys))
for _, key := range keys {
agentID := guards[key]
directory := filepath.Join(root, agentID)
contracts = append(contracts, codexRuntimeContract{
agentID: agentID,
guardPath: filepath.Join(directory, codexGuardScript),
auditPath: filepath.Join(directory, codexAuditScript),
})
}
return false, nil
return contracts, nil
}
+214
View File
@@ -0,0 +1,214 @@
package plugins
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
)
type codexRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateCodexPrivateKey(value string) error {
seed, err := base64.RawURLEncoding.DecodeString(value)
if err != nil || len(seed) != 32 || base64.RawURLEncoding.EncodeToString(seed) != value {
return fmt.Errorf("private key must be a canonical 32-byte base64url value")
}
return nil
}
func validateCodexBaseURL(value string) error {
if strings.ContainsRune(value, '\\') {
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
}
for _, character := range value {
if character < 32 || unicode.IsSpace(character) {
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
}
}
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
}
validScheme := strings.EqualFold(parsed.Scheme, "http") ||
strings.EqualFold(parsed.Scheme, "https")
if !validScheme ||
parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" {
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf(
"base URL must exclude credentials, query parameters, and fragments",
)
}
port := parsed.Port()
if strings.HasSuffix(parsed.Host, ":") {
return fmt.Errorf("base URL must contain a valid port")
}
if port != "" {
value, convertErr := strconv.Atoi(port)
if convertErr != nil || value < 1 || value > 65535 {
return fmt.Errorf("base URL must contain a valid port")
}
}
return nil
}
func validateCodexInstallConfig(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 != codexAgentKey {
return fmt.Errorf("codex installation requires agent name %s", codexAgentKey)
}
if err := validateCodexPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateCodexBaseURL(config.BaseURL)
}
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
if err := validateCodexInstallConfig(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 := &codexRuntimePaths{
runtimeRoot: runtimeRoot,
agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, codexGuardScript),
auditPath: filepath.Join(agentDirectory, codexAuditScript),
}
if !sameCodexPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && !sameCodexPath(config.HookScript, paths.auditPath) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightCodexInstallation(
config InstallConfig,
hooksPath string,
) (*codexRuntimePaths, string, error) {
paths, err := codexAgentPaths(config)
if err != nil {
return nil, "", err
}
if _, err := managedPhysicalDirectoryExists(
filepath.Dir(hooksPath),
"Codex hooks directory",
); err != nil {
return nil, "", err
}
if err := validateCodexRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, "", err
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func buildCodexRuntimeConfig(config InstallConfig) ([]byte, error) {
encoded, err := json.MarshalIndent(agentRuntimeConfig{
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID,
BaseURL: config.BaseURL, Token: config.Token, AgentName: codexAgentKey,
}, "", " ")
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 prepareCodexInstallationChanges(
config InstallConfig,
paths *codexRuntimePaths,
rendered *codexRenderedDocument,
) ([]*fileChange, error) {
runtimeConfig, err := buildCodexRuntimeConfig(config)
if err != nil {
return nil, err
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(codexAgentKey, 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(
codexAgentKey, config.AgentID, "", false, true,
)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(
item.path,
item.label,
item.content,
item.mode,
)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
documentChange, err := prepareRenderedCodexChange(rendered)
if err != nil {
return nil, err
}
return append(changes, documentChange), nil
}
+216 -15
View File
@@ -6,27 +6,228 @@ import (
"path/filepath"
)
func codexConfigPath() (string, error) {
home, err := os.UserHomeDir()
func codexHomePath() (string, error) {
configured := os.Getenv("CODEX_HOME")
if configured == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".codex"), nil
}
info, err := os.Stat(configured)
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
return "", fmt.Errorf("resolve CODEX_HOME at %s: %w", configured, err)
}
return filepath.Join(home, ".codex", "hooks.json"), nil
if !info.IsDir() {
return "", fmt.Errorf("CODEX_HOME is not a directory: %s", configured)
}
canonical, err := filepath.EvalSymlinks(configured)
if err != nil {
return "", fmt.Errorf("canonicalize CODEX_HOME at %s: %w", configured, err)
}
canonical, err = filepath.Abs(canonical)
if err != nil {
return "", fmt.Errorf("resolve canonical CODEX_HOME at %s: %w", configured, err)
}
exists, err := managedPhysicalDirectoryExists(canonical, "CODEX_HOME")
if err != nil {
return "", err
}
if !exists {
return "", fmt.Errorf("CODEX_HOME is missing: %s", canonical)
}
return canonical, nil
}
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
return map[string]any{
"type": "command",
"command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath),
"commandWindows": quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath),
"timeout": 10,
"statusMessage": statusMessage,
func codexConfigPath() (string, error) {
home, err := codexHomePath()
if err != nil {
return "", err
}
return filepath.Join(home, codexConfigFile), nil
}
func codexMatcherGroup(handler map[string]any) map[string]any {
return map[string]any{
"matcher": "*",
"hooks": []any{handler},
func readCodexDocument() (*codexDocument, error) {
filePath, err := codexConfigPath()
if err != nil {
return nil, err
}
directory := filepath.Dir(filePath)
if _, err := managedPhysicalDirectoryExists(directory, "Codex hooks directory"); err != nil {
return nil, err
}
snapshot, err := readManagedFile(filePath, "Codex user hooks", maxManagedSourceBytes)
if err != nil {
return nil, err
}
if snapshot == nil {
return createCodexDocument(filePath), nil
}
return parseCodexDocument(filePath, snapshot.contents)
}
func prepareRenderedCodexChange(rendered *codexRenderedDocument) (*fileChange, error) {
if rendered == nil || !rendered.changed {
return nil, nil
}
return prepareSourceChange(
rendered.document.filePath,
"Codex user hooks",
rendered.document.raw,
rendered.document.exists,
rendered.next,
0600,
rendered.remove,
)
}
func writeCodexChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
hooksDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory(hooksDirectory, "Codex hooks directory"); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
func readCodexRuntimeConfig(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 validateCodexRuntimeIdentity(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 := readCodexRuntimeConfig(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, codexGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, codexAuditScript), "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"] != codexAgentKey ||
!sameCodexAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Codex agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func codexRuntimeFilesExist(contracts []codexRuntimeContract) (bool, error) {
for _, contract := range contracts {
agentDirectory := filepath.Dir(contract.guardPath)
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil {
return false, err
}
if !directoryExists {
continue
}
config, exists, err := readCodexRuntimeConfig(
filepath.Join(agentDirectory, "config.json"),
)
if err != nil {
return false, err
}
configuredID, idOK := config["agent_id"].(string)
if !exists || !idOK || config["agent_name"] != codexAgentKey ||
!sameCodexAgentID(configuredID, contract.agentID) {
continue
}
complete := true
for _, item := range []struct {
path string
label string
limit int64
}{
{contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes},
{contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return false, inspectErr
}
complete = complete && exists
}
if complete {
return true, nil
}
}
return false, nil
}
+426
View File
@@ -0,0 +1,426 @@
package plugins
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func codexSymlinkOrSkip(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symbolic links unavailable: %v", err)
}
}
func assertCodexRuntimeAbsent(t *testing.T, fixture *codexFixture) {
t.Helper()
for _, path := range []string{
fixture.runtimeConfig,
fixture.privateKey,
fixture.guardPath,
fixture.hookPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err)
}
}
}
func assertNoCodexTransactionArtifacts(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(entry.Name(), ".tmp") ||
strings.HasSuffix(entry.Name(), ".rollback") {
t.Errorf("transaction artifact remains at %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk transaction root: %v", err)
}
}
func TestCodexUsesConfiguredHomeAndLeavesAdditiveSourcesUntouched(t *testing.T) {
fixture := prepareCodexFixture(t, "")
customHome := filepath.Join(t.TempDir(), "custom codex home")
if err := os.MkdirAll(customHome, 0700); err != nil {
t.Fatalf("create CODEX_HOME: %v", err)
}
t.Setenv("CODEX_HOME", customHome)
userTOML := filepath.Join(customHome, "config.toml")
source := []byte("[hooks]\nexample = true\n")
if err := os.WriteFile(userTOML, source, 0600); err != nil {
t.Fatalf("write additive Codex config: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
wantHooks, err := codexConfigPath()
if err != nil {
t.Fatalf("resolve configured Codex hooks: %v", err)
}
status, err := fixture.plugin.Status()
if err != nil || status.ConfigPath != wantHooks || !status.Installed {
t.Fatalf("Codex status = %#v, %v", status, err)
}
actual, err := os.ReadFile(userTOML)
if err != nil || string(actual) != string(source) {
t.Fatalf("additive config changed: %q, %v", actual, err)
}
}
func TestCodexConfiguredHomeMustExistAndBeDirectory(t *testing.T) {
fixture := prepareCodexFixture(t, "")
missing := filepath.Join(t.TempDir(), "missing")
t.Setenv("CODEX_HOME", missing)
if err := fixture.plugin.PreflightInstall(fixture.config); err == nil ||
!strings.Contains(err.Error(), "resolve CODEX_HOME") {
t.Fatalf("missing CODEX_HOME error = %v", err)
}
filePath := filepath.Join(t.TempDir(), "codex-home-file")
if err := os.WriteFile(filePath, []byte("file"), 0600); err != nil {
t.Fatalf("write CODEX_HOME file: %v", err)
}
t.Setenv("CODEX_HOME", filePath)
if _, err := codexConfigPath(); err == nil || !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("file CODEX_HOME error = %v", err)
}
assertCodexRuntimeAbsent(t, fixture)
}
func TestCodexConfiguredHomeCanonicalizesDirectoryLinks(t *testing.T) {
fixture := prepareCodexFixture(t, "")
target := filepath.Join(t.TempDir(), "codex-target")
link := filepath.Join(t.TempDir(), "codex-link")
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create CODEX_HOME target: %v", err)
}
codexSymlinkOrSkip(t, target, link)
t.Setenv("CODEX_HOME", link)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install through canonical CODEX_HOME: %v", err)
}
if _, err := os.Stat(filepath.Join(target, codexConfigFile)); err != nil {
t.Fatalf("canonical hooks file missing: %v", err)
}
}
func TestCodexUninstallPreservesUnconfiguredEmptyHooks(t *testing.T) {
source := []byte("{\r\n \"description\": \"user\",\r\n \"hooks\": {}\r\n}\r\n")
fixture := prepareCodexFixture(t, string(source))
if err := fixture.plugin.Uninstall(codexTestAgentID); err != nil {
t.Fatalf("uninstall unconfigured Codex hooks: %v", err)
}
actual, err := os.ReadFile(fixture.configPath)
if err != nil || string(actual) != string(source) {
t.Fatalf("unconfigured hooks changed: %q, %v", actual, err)
}
}
func TestCodexInstallMigratesLegacyCommandHandlers(t *testing.T) {
fixture := prepareCodexFixture(t, "")
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
legacy := func(scriptPath, status string) map[string]any {
return map[string]any{
"type": "command",
"command": quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath),
"commandWindows": quoteWindowsArgument(nodePath) + " " +
quoteWindowsArgument(scriptPath),
"timeout": codexHookTimeout, "statusMessage": status,
}
}
settings := map[string]any{"hooks": map[string]any{
"PreToolUse": []any{codexMatcherGroup(legacy(fixture.guardPath, codexGuardStatus))},
"PostToolUse": []any{codexMatcherGroup(legacy(fixture.hookPath, codexAuditStatus))},
}}
encoded, err := json.Marshal(settings)
if err != nil {
t.Fatalf("encode legacy hooks: %v", err)
}
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil {
t.Fatalf("create hooks directory: %v", err)
}
if err := os.WriteFile(fixture.configPath, encoded, 0600); err != nil {
t.Fatalf("write legacy hooks: %v", err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("migrate legacy Codex hooks: %v", err)
}
hooks := requireObject(t, readCodexTestObject(t, fixture.configPath)["hooks"])
if len(requireArray(t, hooks["PreToolUse"])) != 1 ||
len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("legacy handlers remain: %#v", hooks)
}
guard := requireCodexHandler(t, map[string]any{"hooks": hooks}, "PreToolUse", codexGuardStatus)
if !strings.Contains(guard["commandWindows"].(string), "EncodedCommand") {
t.Fatalf("migrated Windows command = %q", guard["commandWindows"])
}
}
func TestCodexRejectsLinkedDefaultHooksDirectory(t *testing.T) {
fixture := prepareCodexFixture(t, "")
target := t.TempDir()
codexSymlinkOrSkip(t, target, filepath.Dir(fixture.configPath))
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical directory") {
t.Fatalf("linked hooks directory error = %v", err)
}
assertCodexRuntimeAbsent(t, fixture)
entries, readErr := os.ReadDir(target)
if readErr != nil || len(entries) != 0 {
t.Fatalf("linked directory target changed: %#v, %v", entries, readErr)
}
}
func TestCodexRejectsLinkedHookAndRuntimeFiles(t *testing.T) {
for _, name := range []string{"hooks", "config", "key", "guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCodexFixture(t, "")
path := map[string]string{
"hooks": fixture.configPath, "config": fixture.runtimeConfig,
"key": fixture.privateKey, "guard": fixture.guardPath,
"audit": fixture.hookPath,
}[name]
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("create linked file directory: %v", err)
}
target := filepath.Join(t.TempDir(), name+"-target")
source := []byte("external\n")
if err := os.WriteFile(target, source, 0600); err != nil {
t.Fatalf("write linked target: %v", err)
}
codexSymlinkOrSkip(t, target, path)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical file") {
t.Fatalf("linked %s error = %v", name, err)
}
actual, readErr := os.ReadFile(target)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("linked %s target changed: %q, %v", name, actual, readErr)
}
})
}
}
func TestCodexRejectsOrphanAndMismatchedRuntimeIdentity(t *testing.T) {
for _, testCase := range []struct {
name, config string
}{
{"orphan", ""},
{"mismatch", `{"agent_name":"cursor","agent_id":"agent-1"}`},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if testCase.config != "" {
if err := os.WriteFile(fixture.runtimeConfig, []byte(testCase.config), 0600); err != nil {
t.Fatalf("write runtime config: %v", err)
}
} else if err := os.WriteFile(fixture.guardPath, []byte("orphan"), 0700); err != nil {
t.Fatalf("write orphan guard: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "runtime") {
t.Fatalf("runtime identity error = %v", err)
}
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Codex hooks exist after identity rejection: %v", err)
}
})
}
}
func TestCodexInstallRollsBackAllFilesOnHooksCommitFailure(t *testing.T) {
source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`)
fixture := prepareCodexFixture(t, string(source))
fixture.plugin.rename = func(source, destination string) error {
if sameCodexPath(destination, fixture.configPath) {
return errors.New("injected Codex hooks commit failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "injected Codex hooks commit failure") {
t.Fatalf("install error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("hooks config changed: %q, %v", actual, readErr)
}
assertCodexRuntimeAbsent(t, fixture)
assertNoCodexTransactionArtifacts(t, fixture.homeDir)
}
func TestCodexInstallPreservesConcurrentHooksMutation(t *testing.T) {
fixture := prepareCodexFixture(t, `{"hooks":{}}`)
concurrent := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"command":"concurrent"}]}]}}`)
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if !mutated && strings.HasSuffix(source, ".tmp") {
mutated = true
if err := os.WriteFile(fixture.configPath, concurrent, 0600); 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 install error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(concurrent) {
t.Fatalf("concurrent hooks changed: %q, %v", actual, readErr)
}
assertCodexRuntimeAbsent(t, fixture)
assertNoCodexTransactionArtifacts(t, fixture.homeDir)
}
func TestCodexInstallDetectsConcurrentHooksIdentityReplacement(t *testing.T) {
source := []byte(`{"hooks":{}}`)
fixture := prepareCodexFixture(t, string(source))
mutated := false
fixture.plugin.rename = func(stagedPath, destination string) error {
if !mutated && strings.HasSuffix(stagedPath, ".tmp") {
mutated = true
oldPath := fixture.configPath + ".external"
if err := os.Rename(fixture.configPath, oldPath); err != nil {
return err
}
if err := os.WriteFile(fixture.configPath, source, 0600); err != nil {
return err
}
}
return os.Rename(stagedPath, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("concurrent identity error = %v", err)
}
actual, readErr := os.ReadFile(fixture.configPath)
if readErr != nil || string(actual) != string(source) {
t.Fatalf("replacement hooks changed: %q, %v", actual, readErr)
}
assertCodexRuntimeAbsent(t, fixture)
assertNoCodexTransactionArtifacts(t, fixture.homeDir)
}
func TestCodexStatusRejectsLinkedRuntimeFiles(t *testing.T) {
for _, name := range []string{"config", "key", "guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
path := map[string]string{
"config": fixture.runtimeConfig, "key": fixture.privateKey,
"guard": fixture.guardPath, "audit": fixture.hookPath,
}[name]
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read runtime file: %v", err)
}
target := filepath.Join(t.TempDir(), name+"-target")
if err := os.WriteFile(target, content, 0600); err != nil {
t.Fatalf("write linked status target: %v", err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove runtime file: %v", err)
}
codexSymlinkOrSkip(t, target, path)
status, err := fixture.plugin.Status()
if err == nil || status.Installed {
t.Fatalf("Codex status = %#v, %v", status, err)
}
})
}
}
func TestCodexStatusRequiresExactMatcherAndHandlerIdentity(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
settings := readCodexTestObject(t, fixture.configPath)
hooks := requireObject(t, settings["hooks"])
preGroup := requireObject(t, requireArray(t, hooks["PreToolUse"])[0])
preGroup["matcher"] = "Bash"
encoded, err := json.Marshal(settings)
if err != nil {
t.Fatalf("encode modified hooks: %v", err)
}
if err := os.WriteFile(fixture.configPath, encoded, 0600); err != nil {
t.Fatalf("write modified hooks: %v", err)
}
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("modified matcher status = %#v, %v", status, err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair Codex hooks: %v", err)
}
settings = readCodexTestObject(t, fixture.configPath)
handler := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
handler["extra"] = true
encoded, err = json.Marshal(settings)
if err != nil {
t.Fatalf("encode modified handler: %v", err)
}
if err := os.WriteFile(fixture.configPath, encoded, 0600); err != nil {
t.Fatalf("write modified handler: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("modified handler status = %#v, %v", status, err)
}
}
func TestCodexInstallValidatesIdentityCredentialsAndOrigin(t *testing.T) {
for _, testCase := range []struct {
name string
mutate func(*InstallConfig)
}{
{"agent name", func(config *InstallConfig) { config.AgentName = "cursor" }},
{"agent ID", func(config *InstallConfig) { config.AgentID = "../escape" }},
{"private key", func(config *InstallConfig) { config.PrivateKey = "invalid" }},
{"scheme", func(config *InstallConfig) { config.BaseURL = "file:///tmp/elydora" }},
{"credentials", func(config *InstallConfig) { config.BaseURL = "https://user:secret@api.elydora.com" }},
{"query", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com?tenant=one" }},
{"backslash", func(config *InstallConfig) { config.BaseURL = `https://api.elydora.com\evil` }},
{"space", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com/invalid path" }},
{"port", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com:invalid" }},
{"guard path", func(config *InstallConfig) { config.GuardScriptPath = "unmanaged.js" }},
{"audit path", func(config *InstallConfig) { config.HookScript = "unmanaged.js" }},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCodexFixture(t, "")
testCase.mutate(&fixture.config)
err := fixture.plugin.Install(fixture.config)
if err == nil {
t.Fatal("install accepted invalid configuration")
}
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Codex hooks exist after validation failure: %v", err)
}
})
}
}
+374
View File
@@ -0,0 +1,374 @@
package plugins
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
)
type codexTestAPI struct {
mu sync.Mutex
status string
postStatus int
getAuth string
postAuth string
postBody string
operations []map[string]any
}
func (api *codexTestAPI) ServeHTTP(response http.ResponseWriter, request *http.Request) {
api.mu.Lock()
defer api.mu.Unlock()
response.Header().Set("Content-Type", "application/json")
switch {
case request.Method == http.MethodGet &&
request.URL.EscapedPath() == "/v1/agents/agent-1":
api.getAuth = request.Header.Get("Authorization")
_ = json.NewEncoder(response).Encode(map[string]any{
"agent": map[string]any{"status": api.status},
})
case request.Method == http.MethodPost && request.URL.Path == "/v1/operations":
api.postAuth = request.Header.Get("Authorization")
var operation map[string]any
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
http.Error(response, err.Error(), http.StatusBadRequest)
return
}
api.operations = append(api.operations, operation)
status := api.postStatus
if status == 0 {
status = http.StatusCreated
}
response.WriteHeader(status)
body := api.postBody
if body == "" {
body = `{"operation":{"accepted":true}}`
}
_, _ = response.Write([]byte(body))
default:
http.NotFound(response, request)
}
}
func (api *codexTestAPI) snapshot() (string, string, []map[string]any) {
api.mu.Lock()
defer api.mu.Unlock()
return api.getAuth, api.postAuth, append([]map[string]any(nil), api.operations...)
}
func codexCommandKey() string {
if os.PathSeparator == '\\' {
return "commandWindows"
}
return "command"
}
func codexOfficialPayload(event string) map[string]any {
payload := map[string]any{
"hook_event_name": event,
"session_id": "session-1",
"turn_id": "turn-1",
"transcript_path": nil,
"cwd": "C:/workspace",
"model": "gpt-5",
"permission_mode": "default",
"tool_name": "Bash",
"tool_use_id": "call-1",
"tool_input": map[string]any{"command": "echo test"},
}
if event == "PostToolUse" {
payload["tool_response"] = map[string]any{
"output": "test",
"success": true,
}
}
return payload
}
func installCodexRuntimeFixture(
t *testing.T,
status string,
) (*codexFixture, *codexTestAPI, *httptest.Server) {
t.Helper()
api := &codexTestAPI{status: status}
server := httptest.NewServer(api)
fixture := prepareCodexFixture(t, "")
fixture.config.BaseURL = server.URL
fixture.config.Token = "ely_test_token"
if err := fixture.plugin.Install(fixture.config); err != nil {
server.Close()
t.Fatalf("install Codex hooks: %v", err)
}
return fixture, api, server
}
func TestCodexGeneratedRuntimePreservesNativePayloadAuthAndChain(t *testing.T) {
fixture, api, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
settings := readCodexTestObject(t, fixture.configPath)
key := codexCommandKey()
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
audit := requireCodexHandler(t, settings, "PostToolUse", codexAuditStatus)
if exitCode, stderr := runCodexCommand(
t, guard[key].(string), fixture.homeDir, codexOfficialPayload("PreToolUse"),
); exitCode != 0 || stderr != "" {
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
}
first := codexOfficialPayload("PostToolUse")
second := codexOfficialPayload("PostToolUse")
second["tool_use_id"] = "call-2"
second["tool_response"] = map[string]any{"error": "command failed", "success": false}
for _, payload := range []map[string]any{first, second} {
if exitCode, stderr := runCodexCommand(
t, audit[key].(string), fixture.homeDir, payload,
); exitCode != 0 || stderr != "" {
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
}
}
getAuth, postAuth, operations := api.snapshot()
if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" {
t.Fatalf("authorization headers = %q, %q", getAuth, postAuth)
}
if len(operations) != 2 || !reflect.DeepEqual(operations[0]["payload"], first) ||
!reflect.DeepEqual(operations[1]["payload"], second) {
t.Fatalf("native operations = %#v", operations)
}
if operations[0]["prev_chain_hash"] != "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ||
operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] {
t.Fatalf("operation chain = %#v", operations)
}
}
func TestCodexGeneratedGuardBlocksFrozenAndRevokedAgents(t *testing.T) {
for _, status := range []string{"frozen", "revoked"} {
t.Run(status, func(t *testing.T) {
fixture, _, server := installCodexRuntimeFixture(t, status)
defer server.Close()
guard := requireCodexHandler(
t, readCodexTestObject(t, fixture.configPath), "PreToolUse", codexGuardStatus,
)
exitCode, stderr := runCodexCommand(
t, guard[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PreToolUse"),
)
if exitCode != 2 || !strings.Contains(stderr, "Tool execution blocked") {
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
}
})
}
}
func TestCodexFailOpenGuardReportsInputConfigStatusAndAPIFailures(t *testing.T) {
fixture, api, server := installCodexRuntimeFixture(t, "active")
settings := readCodexTestObject(t, fixture.configPath)
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
command := guard[codexCommandKey()].(string)
runtimeConfig, err := os.ReadFile(fixture.runtimeConfig)
if err != nil {
t.Fatalf("read runtime config: %v", err)
}
malformedExit, malformedError := runCodexRawCommand(
t, command, fixture.homeDir, []byte("{ malformed"),
)
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
configExit, configError := runCodexCommand(
t, command, fixture.homeDir, codexOfficialPayload("PreToolUse"),
)
if err := os.WriteFile(fixture.runtimeConfig, runtimeConfig, 0600); err != nil {
t.Fatalf("restore runtime config: %v", err)
}
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
api.mu.Lock()
api.status = "unknown"
api.mu.Unlock()
statusExit, statusError := runCodexCommand(
t, command, fixture.homeDir, codexOfficialPayload("PreToolUse"),
)
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
server.Close()
apiExit, apiError := runCodexCommand(
t, command, fixture.homeDir, codexOfficialPayload("PreToolUse"),
)
for _, result := range []struct {
exit int
err string
want string
}{
{malformedExit, malformedError, "invalid JSON"},
{configExit, configError, "Failed to read agent config"},
{statusExit, statusError, "invalid agent status"},
{apiExit, apiError, "Failed to resolve agent status"},
} {
if result.exit != 0 || !strings.Contains(result.err, result.want) {
t.Fatalf("fail-open result = %d, %q; want %q", result.exit, result.err, result.want)
}
}
}
func TestCodexFailOpenAuditRecordsRuntimeAndAPIFailures(t *testing.T) {
for _, testCase := range []struct {
name, target, source, want string
}{
{"input", "input", "{ malformed", "invalid JSON"},
{"config", "config.json", "{ malformed", "agent config"},
{"key", "private.key", "invalid", "Private key"},
{"chain", "chain-state.json", "{ malformed", "Chain state"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture, _, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
audit := requireCodexHandler(
t, readCodexTestObject(t, fixture.configPath), "PostToolUse", codexAuditStatus,
)
input := []byte(testCase.source)
if testCase.target != "input" {
if err := os.WriteFile(
filepath.Join(fixture.agentDir, testCase.target),
[]byte(testCase.source),
0600,
); err != nil {
t.Fatalf("write invalid runtime source: %v", err)
}
input, _ = json.Marshal(codexOfficialPayload("PostToolUse"))
}
exitCode, _ := runCodexRawCommand(
t, audit[codexCommandKey()].(string), fixture.homeDir, input,
)
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if exitCode != 0 || err != nil ||
!strings.Contains(strings.ToLower(string(log)), strings.ToLower(testCase.want)) {
t.Fatalf("audit failure = %d, %q, %v", exitCode, log, err)
}
})
}
fixture, api, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
api.mu.Lock()
api.postStatus = http.StatusInternalServerError
api.mu.Unlock()
audit := requireCodexHandler(
t, readCodexTestObject(t, fixture.configPath), "PostToolUse", codexAuditStatus,
)
exitCode, _ := runCodexCommand(
t, audit[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PostToolUse"),
)
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if exitCode != 0 || err != nil || !strings.Contains(string(log), "HTTP 500") {
t.Fatalf("audit API failure = %d, %q, %v", exitCode, log, err)
}
}
func TestCodexAuditRejectsInvalidServerChainHash(t *testing.T) {
fixture, api, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
api.mu.Lock()
api.postStatus = http.StatusBadRequest
api.postBody = `{"error":{"code":"PREV_HASH_MISMATCH","message":"Expected prev_chain_hash \"invalid\""}}`
api.mu.Unlock()
audit := requireCodexHandler(
t, readCodexTestObject(t, fixture.configPath), "PostToolUse", codexAuditStatus,
)
exitCode, _ := runCodexCommand(
t, audit[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PostToolUse"),
)
if exitCode != 0 {
t.Fatalf("audit exit = %d", exitCode)
}
chainPath := filepath.Join(fixture.agentDir, "chain-state.json")
if _, err := os.Stat(chainPath); !os.IsNotExist(err) {
t.Fatalf("invalid server hash created chain state: %v", err)
}
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if err != nil || !strings.Contains(string(log), "invalid chain hash") {
t.Fatalf("audit error log = %q, %v", log, err)
}
}
func TestCodexGeneratedRuntimePreservesLinkedStateTargets(t *testing.T) {
for _, targetName := range []string{"status-cache.json", "chain-state.json", "error.log"} {
t.Run(targetName, func(t *testing.T) {
fixture, _, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
target := filepath.Join(t.TempDir(), targetName+".target")
source := []byte("external")
if err := os.WriteFile(target, source, 0600); err != nil {
t.Fatalf("write state target: %v", err)
}
codexSymlinkOrSkip(t, target, filepath.Join(fixture.agentDir, targetName))
settings := readCodexTestObject(t, fixture.configPath)
var command string
var input []byte
if targetName == "status-cache.json" {
command = requireCodexHandler(
t, settings, "PreToolUse", codexGuardStatus,
)[codexCommandKey()].(string)
input, _ = json.Marshal(codexOfficialPayload("PreToolUse"))
} else {
command = requireCodexHandler(
t, settings, "PostToolUse", codexAuditStatus,
)[codexCommandKey()].(string)
if targetName == "error.log" {
input = []byte("{ malformed")
} else {
input, _ = json.Marshal(codexOfficialPayload("PostToolUse"))
}
}
if exitCode, _ := runCodexRawCommand(
t, command, fixture.homeDir, input,
); exitCode != 0 {
t.Fatalf("runtime exit = %d", exitCode)
}
actual, err := os.ReadFile(target)
if err != nil || string(actual) != string(source) {
t.Fatalf("linked state target changed: %q, %v", actual, err)
}
})
}
}
func TestCodexGeneratedHooksReportUnsafeRuntimeOrigin(t *testing.T) {
fixture, _, server := installCodexRuntimeFixture(t, "active")
defer server.Close()
config := readCodexTestObject(t, fixture.runtimeConfig)
config["base_url"] = `https://api.elydora.com\evil`
encoded, err := json.Marshal(config)
if err != nil {
t.Fatalf("encode unsafe runtime config: %v", err)
}
if err := os.WriteFile(fixture.runtimeConfig, encoded, 0600); err != nil {
t.Fatalf("write unsafe runtime config: %v", err)
}
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
settings := readCodexTestObject(t, fixture.configPath)
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
audit := requireCodexHandler(t, settings, "PostToolUse", codexAuditStatus)
guardExit, guardError := runCodexCommand(
t, guard[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PreToolUse"),
)
auditExit, _ := runCodexCommand(
t, audit[codexCommandKey()].(string), fixture.homeDir,
codexOfficialPayload("PostToolUse"),
)
log, readErr := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
if guardExit != 0 || !strings.Contains(guardError, "absolute HTTP or HTTPS URL") ||
auditExit != 0 || readErr != nil ||
!strings.Contains(string(log), "absolute HTTP or HTTPS URL") {
t.Fatalf("unsafe origin results = %d, %q, %d, %q, %v", guardExit, guardError, auditExit, log, readErr)
}
}
+67 -40
View File
@@ -17,6 +17,7 @@ const (
codexTestAgentID = "agent-1"
codexGuardStatus = "Checking Elydora agent state"
codexAuditStatus = "Recording Elydora tool use"
codexPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
)
type codexFixture struct {
@@ -31,21 +32,15 @@ type codexFixture struct {
privateKey string
}
func prepareCodexFixture(t *testing.T, existingRaw string, createGuard bool) *codexFixture {
func prepareCodexFixture(t *testing.T, existingRaw string) *codexFixture {
t.Helper()
homeDir := filepath.Join(t.TempDir(), "home with spaces")
homeDir := filepath.Join(t.TempDir(), "home with 'quote %ELYDORA_HOOK_PATH%")
agentDir := filepath.Join(homeDir, ".elydora", codexTestAgentID)
configPath := filepath.Join(homeDir, ".codex", "hooks.json")
guardPath := filepath.Join(agentDir, "guard.js")
if err := os.MkdirAll(agentDir, 0755); err != nil {
t.Fatalf("create agent directory: %v", err)
}
if createGuard {
guard := "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"
if err := os.WriteFile(guardPath, []byte(guard), 0600); err != nil {
t.Fatalf("write guard: %v", err)
}
}
if existingRaw != "" {
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
t.Fatalf("create Codex config directory: %v", err)
@@ -56,6 +51,7 @@ func prepareCodexFixture(t *testing.T, existingRaw string, createGuard bool) *co
}
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv("CODEX_HOME", "")
return &codexFixture{
plugin: &CodexPlugin{},
@@ -65,7 +61,7 @@ func prepareCodexFixture(t *testing.T, existingRaw string, createGuard bool) *co
AgentName: "codex",
OrgID: "org-1",
AgentID: codexTestAgentID,
PrivateKey: "test-key",
PrivateKey: codexPrivateKey,
KID: "kid-1",
BaseURL: "https://api.elydora.test",
GuardScriptPath: guardPath,
@@ -113,6 +109,11 @@ func runCodexCommand(t *testing.T, command, homeDir string, payload map[string]a
if err != nil {
t.Fatalf("marshal Codex payload: %v", err)
}
return runCodexRawCommand(t, command, homeDir, encoded)
}
func runCodexRawCommand(t *testing.T, command, homeDir string, encoded []byte) (int, string) {
t.Helper()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
commandFile := filepath.Join(t.TempDir(), "run-codex-hook.cmd")
@@ -127,7 +128,7 @@ func runCodexCommand(t *testing.T, command, homeDir string, payload map[string]a
cmd.Stdin = bytes.NewReader(encoded)
var stderr bytes.Buffer
cmd.Stderr = &stderr
err = cmd.Run()
err := cmd.Run()
if err == nil {
return 0, stderr.String()
}
@@ -153,7 +154,7 @@ func TestCodexInstallPreservesHooksAndIsIdempotent(t *testing.T) {
"SessionStart": [{"hooks": [{"type": "command", "command": "existing-command"}]}],
"PreToolUse": [{"matcher": "Read", "hooks": []}]
}
}`, true)
}`)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
@@ -185,10 +186,14 @@ func TestCodexInstallPreservesHooksAndIsIdempotent(t *testing.T) {
}
func TestCodexCommandsBlockAndForwardOfficialPayload(t *testing.T) {
fixture := prepareCodexFixture(t, "", true)
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
guardScript := "process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"
if err := os.WriteFile(fixture.guardPath, []byte(guardScript), 0700); err != nil {
t.Fatalf("write blocking guard: %v", err)
}
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
captureJSON, err := json.Marshal(capturePath)
if err != nil {
@@ -219,6 +224,9 @@ func TestCodexCommandsBlockAndForwardOfficialPayload(t *testing.T) {
"tool_input": map[string]any{"command": "echo test"},
}
guard := requireCodexHandler(t, settings, "PreToolUse", codexGuardStatus)
if strings.Contains(guard["commandWindows"].(string), "%ELYDORA_HOOK_PATH%") {
t.Fatalf("Windows command exposes an expandable path: %q", guard["commandWindows"])
}
exitCode, stderr := runCodexCommand(t, guard[commandKey].(string), fixture.homeDir, payload)
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
@@ -238,7 +246,7 @@ func TestCodexCommandsBlockAndForwardOfficialPayload(t *testing.T) {
}
func TestCodexStatusRequiresBothRuntimes(t *testing.T) {
fixture := prepareCodexFixture(t, "", true)
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
@@ -253,23 +261,38 @@ func TestCodexStatusRequiresBothRuntimes(t *testing.T) {
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
t.Fatalf("degraded status = %#v, %v", status, err)
}
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("repair Codex hooks: %v", err)
}
if err := os.Remove(fixture.privateKey); err != nil {
t.Fatalf("remove private key: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
t.Fatalf("missing key status = %#v, %v", status, err)
}
}
func TestCodexInstallRejectsMissingGuardBeforeWrites(t *testing.T) {
fixture := prepareCodexFixture(t, "", false)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") {
t.Fatalf("install error = %v", err)
func TestCodexInstallCommitsAllManagedRuntimeFiles(t *testing.T) {
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("write occurred at %s: %v", path, err)
for _, path := range []string{
fixture.configPath,
fixture.guardPath,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
} {
if info, err := os.Lstat(path); err != nil || !info.Mode().IsRegular() {
t.Fatalf("managed file %s = %v, %v", path, info, err)
}
}
}
func TestCodexStatusSurfacesMalformedReferencedRuntimeMetadata(t *testing.T) {
fixture := prepareCodexFixture(t, "", true)
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
@@ -285,7 +308,7 @@ func TestCodexInstallPreservesMatchingUserStatusText(t *testing.T) {
fixture := prepareCodexFixture(t, `{"hooks":{
"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"node ~/.elydora/user/guard.js.backup","statusMessage":"Checking Elydora agent state"}]}],
"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"node ~/.elydora/user/hook.js.backup","statusMessage":"Recording Elydora tool use"}]}]
}}`, true)
}}`)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
@@ -296,24 +319,21 @@ func TestCodexInstallPreservesMatchingUserStatusText(t *testing.T) {
}
func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) {
fixture := prepareCodexFixture(t, `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"existing-command"}]}]}}`, true)
fixture := prepareCodexFixture(t, `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"existing-command"}]}]}}`)
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
settings := readCodexTestObject(t, fixture.configPath)
otherDir := filepath.Join(filepath.Dir(fixture.agentDir), "agent-10")
for _, contract := range []struct{ event, status string }{
{"PreToolUse", codexGuardStatus},
{"PostToolUse", codexAuditStatus},
nodePath, err := resolveNodeRuntime()
if err != nil {
t.Fatalf("resolve Node.js: %v", err)
}
for _, contract := range []struct{ event, status, script string }{
{"PreToolUse", codexGuardStatus, codexGuardScript},
{"PostToolUse", codexAuditStatus, codexAuditScript},
} {
handler := requireCodexHandler(t, settings, contract.event, contract.status)
other := map[string]any{}
for key, value := range handler {
other[key] = value
}
for _, key := range []string{"command", "commandWindows"} {
other[key] = strings.ReplaceAll(other[key].(string), fixture.agentDir, otherDir)
}
other := codexHandler(nodePath, filepath.Join(otherDir, contract.script), contract.status)
hooks := requireObject(t, settings["hooks"])
hooks[contract.event] = append(requireArray(t, hooks[contract.event]), map[string]any{
"matcher": "*", "hooks": []any{other},
@@ -334,13 +354,16 @@ func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) {
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("unexpected remaining hooks: %#v", hooks)
}
if !strings.Contains(requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"].(string), otherDir) {
_, remainingScript, parsed := parseCodexPOSIXCommand(
requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"],
)
if !parsed || !sameCodexPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) {
t.Fatalf("other agent handler was removed: %#v", hooks)
}
}
func TestCodexUninstallRemovesOwnedConfig(t *testing.T) {
fixture := prepareCodexFixture(t, "", true)
fixture := prepareCodexFixture(t, "")
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Codex hooks: %v", err)
}
@@ -354,13 +377,17 @@ func TestCodexUninstallRemovesOwnedConfig(t *testing.T) {
func TestCodexMalformedConfigAndShapesPreventRuntimeWrites(t *testing.T) {
for _, testCase := range []struct{ name, raw, want string }{
{"malformed", "{ malformed", "parse Codex hooks config"},
{"malformed", "{ malformed", "parse Codex user hooks"},
{"comments", `{"hooks":{} // comment\n}`, "invalid character"},
{"trailing comma", `{"hooks":{},}`, "invalid character"},
{"duplicate root", `{"hooks":{},"hooks":{}}`, "duplicate"},
{"nested duplicate", `{"hooks":{"PreToolUse":[{"hooks":[],"hooks":[]}]}}`, "duplicate"},
{"null-hooks", `{"hooks":null}`, `field "hooks" must be an object`},
{"null-event", `{"hooks":{"PreToolUse":null}}`, `field "hooks.PreToolUse" must be an array`},
{"null-handlers", `{"hooks":{"PreToolUse":[{"hooks":null}]}}`, "matcher group must contain a hooks array"},
{"null-handlers", `{"hooks":{"PreToolUse":[{"hooks":null}]}}`, "must contain a hooks array"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCodexFixture(t, testCase.raw, true)
fixture := prepareCodexFixture(t, testCase.raw)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
+166 -80
View File
@@ -2,10 +2,7 @@ package plugins
import "fmt"
// 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
// tool call, and fails open if the API is unreachable or config is missing.
// GenerateGuardScript returns a self-contained agent status guard.
func GenerateGuardScript(agentName string, agentID string) string {
return generateGuardScript(agentName, agentID, "", false)
}
@@ -14,11 +11,8 @@ func generateGuardScript(agentName, agentID, successOutput string, failClosed bo
return fmt.Sprintf(`#!/usr/bin/env node
'use strict';
// ---------------------------------------------------------------------------
// Elydora agent guard (PreToolUse) for: %s
// Generated by @elydora/sdk — DO NOT EDIT
// ---------------------------------------------------------------------------
// Elydora agent guard for %s. Generated by Elydora SDK.
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
@@ -34,105 +28,197 @@ const CACHE_TTL_MS = 60000;
%s
process.stdin.resume();
process.stdin.on('data', () => {});
function validateStatus(value, source) {
if (!['active', 'frozen', 'revoked'].includes(value)) {
throw new Error(source + ' contains an invalid agent status');
}
return value;
}
function blockAgent(status) {
const state = status === 'frozen' ? 'is frozen by Elydora' : 'is revoked in Elydora';
process.stderr.write(
'Agent "' + AGENT_NAME + '" ' + state + '. Tool execution blocked.\n',
);
const message = 'Agent "' + AGENT_NAME + '" ' + state + '.';
if (SUCCESS_OUTPUT) {
process.stdout.write(JSON.stringify({
permission: 'deny',
userMessage: message,
agentMessage: message + ' Tool execution is blocked.',
}) + '\n');
}
process.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\n');
process.exitCode = 2;
}
async function main() {
let config;
try {
config = JSON.parse(readProtectedFile(
CONFIG_PATH,
'Agent config',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8'));
} catch (err) {
if (FAIL_CLOSED) {
throw new Error('Failed to read agent config: ' + err.message);
}
process.stderr.write('[Elydora guard] Failed to read agent config: ' + err.message + '\n');
async function readInput() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString('utf-8').trim();
if (!raw) {
if (FAIL_CLOSED) throw new Error('Hook input is empty');
process.stderr.write(
'[Elydora guard] Hook input is empty; enforcement continued in fail-open mode.\n',
);
return;
}
let value;
try {
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
const cache = JSON.parse(raw);
const age = Date.now() - (cache.cached_at || 0);
if (age < CACHE_TTL_MS) {
if (!['active', 'frozen', 'revoked'].includes(cache.status)) {
throw new Error('Status cache contains an invalid status');
}
if (cache.status !== 'active') {
blockAgent(cache.status);
}
return;
}
} catch (err) {
if (err.code !== 'ENOENT') {
if (FAIL_CLOSED) {
throw new Error('Failed to read status cache: ' + err.message);
}
process.stderr.write('[Elydora guard] Failed to read status cache: ' + err.message + '\n');
}
value = JSON.parse(raw);
} catch (error) {
if (FAIL_CLOSED) throw new Error('Hook input is invalid JSON: ' + error.message);
process.stderr.write(
'[Elydora guard] Hook input is invalid JSON; ' +
'enforcement continued in fail-open mode: ' + error.message + '\n',
);
return;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
if (FAIL_CLOSED) throw new Error('Hook input must contain a JSON object');
process.stderr.write(
'[Elydora guard] Hook input must contain a JSON object; ' +
'enforcement continued in fail-open mode.\n',
);
}
}
const baseURL = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
function readConfig() {
const raw = readProtectedFile(
CONFIG_PATH,
'Agent config',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8');
let config;
try {
config = JSON.parse(raw);
} catch (error) {
throw new Error('Agent config is invalid JSON: ' + error.message);
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
throw new Error('Agent config must contain a JSON object');
}
if (config.agent_id !== AGENT_ID ||
(config.agent_name !== undefined && config.agent_name !== AGENT_NAME)) {
throw new Error('Agent config identity does not match the generated guard');
}
return config;
}
function readStatusCache() {
let raw;
try {
raw = readProtectedFile(
STATUS_CACHE_PATH,
'Status cache',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8');
} catch (error) {
if (hasRuntimeErrorCode(error, 'ENOENT')) return undefined;
throw new Error('Failed to read status cache: ' + error.message);
}
let cache;
try {
cache = JSON.parse(raw);
} catch (error) {
throw new Error('Status cache is invalid JSON: ' + error.message);
}
if (!cache || typeof cache !== 'object' || Array.isArray(cache) ||
!Number.isFinite(cache.cached_at)) {
throw new Error('Status cache has an invalid shape');
}
const age = Date.now() - cache.cached_at;
if (age < 0) throw new Error('Status cache timestamp is in the future');
return { status: validateStatus(cache.status, 'Status cache'), age };
}
function writeStatusCache(status) {
writeProtectedJson(STATUS_CACHE_PATH, 'Status cache', {
status: status,
cached_at: Date.now(),
});
}
function resolveBaseURL(config) {
const value = config.base_url || 'https://api.elydora.com';
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
let parsed;
try {
parsed = new URL(value);
} catch (error) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error(
'Agent config base_url must exclude credentials, query parameters, and fragments',
);
}
return value.replace(/\/+$/, '');
}
async function resolveRemoteStatus(config) {
const baseURL = resolveBaseURL(config);
const token = config.token || '';
const agentID = config.agent_id;
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const headers = { 'Accept': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
const response = await fetch(baseURL + '/v1/agents/' + encodeURIComponent(agentID), {
const headers = { Accept: 'application/json' };
if (token) headers.Authorization = 'Bearer ' + token;
const response = await fetch(baseURL + '/v1/agents/' + encodeURIComponent(AGENT_ID), {
headers: headers,
signal: controller.signal,
});
if (!response.ok) {
throw new Error('Agent status API returned HTTP ' + response.status);
}
if (!response.ok) throw new Error('Agent status API returned HTTP ' + response.status);
const data = await response.json();
const status = data.agent && data.agent.status;
if (!['active', 'frozen', 'revoked'].includes(status)) {
throw new Error('Agent status API returned an invalid status');
}
try {
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
status: status,
cached_at: Date.now(),
}), 'utf-8');
} catch (err) {
process.stderr.write('[Elydora guard] Failed to update status cache: ' + err.message + '\n');
}
if (status !== 'active') {
blockAgent(status);
}
} catch (err) {
if (FAIL_CLOSED) {
throw new Error('Failed to resolve agent status: ' + err.message);
}
process.stderr.write('[Elydora guard] Failed to resolve agent status: ' + err.message + '\n');
return validateStatus(data && data.agent && data.agent.status, 'Agent status API');
} finally {
clearTimeout(timeout);
}
}
async function main() {
await readInput();
let config;
try {
config = readConfig();
} catch (error) {
if (FAIL_CLOSED) throw new Error('Failed to read agent config: ' + error.message);
process.stderr.write('[Elydora guard] Failed to read agent config: ' + error.message + '\n');
return;
}
try {
const cache = readStatusCache();
if (cache && cache.age < CACHE_TTL_MS) {
if (cache.status !== 'active') blockAgent(cache.status);
return;
}
} catch (error) {
if (FAIL_CLOSED) throw error;
process.stderr.write('[Elydora guard] ' + error.message + '\n');
}
try {
const status = await resolveRemoteStatus(config);
writeStatusCache(status);
if (status !== 'active') blockAgent(status);
} catch (error) {
if (FAIL_CLOSED) throw new Error('Failed to resolve agent status: ' + error.message);
process.stderr.write(
'[Elydora guard] Failed to resolve agent status: ' + error.message + '\n',
);
}
}
main().then(() => {
if ((!process.exitCode || process.exitCode === 0) && SUCCESS_OUTPUT) {
process.stdout.write(SUCCESS_OUTPUT);
}
}).catch((err) => {
process.stderr.write('[Elydora guard] ' + (err.stack || err.message || String(err)) + '\n');
}).catch((error) => {
process.stderr.write(
'[Elydora guard] ' + (error.stack || error.message || String(error)) + '\n',
);
process.exitCode = 1;
});
`, agentName, agentName, agentID, successOutput, failClosed, protectedRuntimeFileReader)
+70 -29
View File
@@ -46,6 +46,10 @@ function parsePrivateKey(raw) {
if (!value || value.includes('\r') || value.includes('\n') || value.includes('\0')) {
throw new Error('Private key file must contain exactly one line');
}
const seed = Buffer.from(value, 'base64url');
if (seed.length !== 32 || seed.toString('base64url') !== value) {
throw new Error('Private key must be a canonical 32-byte base64url value');
}
return value;
}
@@ -111,6 +115,13 @@ function signEd25519(privateKeyBase64url, data) {
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
function validateChainHash(value, source) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value)) {
throw new Error(source + ' contains an invalid chain hash');
}
return value;
}
function uuidv7() {
const now = Date.now();
const random = crypto.randomBytes(10);
@@ -150,35 +161,31 @@ function generateNonce() {
// ---------------------------------------------------------------------------
function readChainState() {
let raw;
try {
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
const state = JSON.parse(raw);
return state.prev_chain_hash || ZERO_CHAIN_HASH;
} catch (err) {
if (err.code === 'ENOENT') {
return ZERO_CHAIN_HASH;
}
const failure = new Error('Failed to read chain state: ' + err.message);
if (FAIL_CLOSED) {
throw failure;
}
logError(failure);
return ZERO_CHAIN_HASH;
raw = readProtectedFile(
CHAIN_STATE_PATH,
'Chain state',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8');
} catch (error) {
if (hasRuntimeErrorCode(error, 'ENOENT')) return ZERO_CHAIN_HASH;
throw new Error('Failed to read chain state: ' + error.message);
}
let state;
try {
state = JSON.parse(raw);
} catch (error) {
throw new Error('Chain state is invalid JSON: ' + error.message);
}
if (!state || typeof state !== 'object' || Array.isArray(state)) {
throw new Error('Chain state has an invalid shape');
}
return validateChainHash(state.prev_chain_hash, 'Chain state');
}
function writeChainState(chainHash) {
const tmpPath = CHAIN_STATE_PATH + '.tmp';
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
try {
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
} catch {
// Windows fallback: rename can fail if target exists
try {
fs.unlinkSync(CHAIN_STATE_PATH);
} catch {}
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
}
writeProtectedJson(CHAIN_STATE_PATH, 'Chain state', { prev_chain_hash: chainHash });
}
// ---------------------------------------------------------------------------
@@ -189,7 +196,7 @@ function logError(err) {
try {
const ts = new Date().toISOString();
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\n';
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
appendProtectedText(ERROR_LOG_PATH, 'Elydora error log', msg);
} catch (logFailure) {
process.stderr.write(
'[Elydora audit] Failed to write error log: ' + logFailure.message + '\n',
@@ -197,6 +204,28 @@ function logError(err) {
}
}
function resolveAuditBaseURL(config) {
const value = config.base_url || 'https://api.elydora.com';
if (typeof value !== 'string' || /[\\\u0000-\u0020]/.test(value)) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
let parsed;
try {
parsed = new URL(value);
} catch (error) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
throw new Error('Agent config base_url must be an absolute HTTP or HTTPS URL');
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error(
'Agent config base_url must exclude credentials, query parameters, and fragments',
);
}
return value.replace(/\/+$/, '');
}
// ---------------------------------------------------------------------------
// Main hook logic
// ---------------------------------------------------------------------------
@@ -219,6 +248,9 @@ async function main() {
} catch (err) {
throw new Error('Hook input is invalid JSON: ' + err.message);
}
if (!hookData || typeof hookData !== 'object' || Array.isArray(hookData)) {
throw new Error('Hook input must contain a JSON object');
}
// Extract provider-native tool fields without reshaping the hook input.
const clineToolEvent =
@@ -245,6 +277,12 @@ async function main() {
'Agent config',
MAX_PROTECTED_CONFIG_BYTES,
).toString('utf-8'));
if (!config || typeof config !== 'object' || Array.isArray(config)) {
throw new Error('Agent config must contain a JSON object');
}
if (config.agent_id !== AGENT_ID || config.agent_name !== AGENT_NAME) {
throw new Error('Agent config identity does not match the generated audit hook');
}
privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key'));
} catch (err) {
const failure = new Error('Failed to read agent config/key: ' + err.message);
@@ -258,11 +296,13 @@ async function main() {
const orgId = config.org_id;
const agentId = config.agent_id;
const kid = config.kid;
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
const baseUrl = resolveAuditBaseURL(config);
const token = config.token || '';
if (!orgId || !agentId || !kid) {
if (typeof orgId !== 'string' || !orgId || typeof agentId !== 'string' ||
!agentId || typeof kid !== 'string' || !kid) {
throw new Error('Agent config is missing org_id, agent_id, or kid');
}
if (typeof token !== 'string') throw new Error('Agent config token must be a string');
// Read chain state
const prevChainHash = readChainState();
@@ -337,8 +377,9 @@ async function main() {
// Extract the expected hash from the error message
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
if (match) {
writeChainState(match[1]);
logError(new Error('Chain hash resynced to server: ' + match[1]));
const expectedHash = validateChainHash(match[1], 'Audit API mismatch response');
writeChainState(expectedHash);
logError(new Error('Chain hash resynced to server: ' + expectedHash));
}
}
} catch (err) {
+6 -5
View File
@@ -61,6 +61,7 @@ func runGuard(t *testing.T, scriptPath string, homeDir string) (int, string) {
}
cmd := exec.Command(nodeBinary, scriptPath)
cmd.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
cmd.Stdin = strings.NewReader("{}")
var stderr bytes.Buffer
cmd.Stderr = &stderr
err = cmd.Run()
@@ -100,7 +101,7 @@ func TestGuardAllowsActiveAgent(t *testing.T) {
}
}
func TestGuardBlocksCachedFrozenAgent(t *testing.T) {
func TestGuardRejectsFutureCachedStatus(t *testing.T) {
scriptPath, homeDir, server := writeGuardFixture(t, "active")
defer server.Close()
@@ -118,10 +119,10 @@ func TestGuardBlocksCachedFrozenAgent(t *testing.T) {
}
exitCode, stderr := runGuard(t, scriptPath, homeDir)
if exitCode != 2 {
t.Fatalf("exit code = %d, want 2; stderr = %q", exitCode, stderr)
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0; stderr = %q", exitCode, stderr)
}
if !strings.Contains(stderr, "Tool execution blocked") {
t.Fatalf("stderr = %q, want blocking message", stderr)
if !strings.Contains(stderr, "timestamp is in the future") {
t.Fatalf("stderr = %q, want future timestamp diagnostic", stderr)
}
}
+107
View File
@@ -0,0 +1,107 @@
package plugins
import (
"errors"
"fmt"
"io"
"os"
)
const maxManagedSourceBytes = 2 * 1024 * 1024
type managedFileSnapshot struct {
contents []byte
info os.FileInfo
mode os.FileMode
}
func readManagedFile(
filePath string,
label string,
maximumBytes int64,
) (*managedFileSnapshot, error) {
before, err := os.Lstat(filePath)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("inspect %s at %s: %w", label, filePath, err)
}
if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() {
return nil, fmt.Errorf("%s path is not a physical file: %s", label, filePath)
}
if before.Size() > maximumBytes {
return nil, fmt.Errorf("%s exceeds %d bytes: %s", label, maximumBytes, filePath)
}
file, err := os.Open(filePath) // #nosec G304 -- callers provide confined managed paths.
if err != nil {
return nil, fmt.Errorf("open %s at %s: %w", label, filePath, err)
}
after, statErr := file.Stat()
if statErr != nil {
return nil, errors.Join(
fmt.Errorf("inspect open %s at %s: %w", label, filePath, statErr),
file.Close(),
)
}
if !after.Mode().IsRegular() || !os.SameFile(before, after) {
return nil, errors.Join(
fmt.Errorf("%s changed while opening: %s", label, filePath),
file.Close(),
)
}
raw, readErr := io.ReadAll(io.LimitReader(file, maximumBytes+1))
closeErr := file.Close()
if readErr != nil || closeErr != nil {
var failure error
if readErr != nil {
failure = fmt.Errorf("read %s at %s: %w", label, filePath, readErr)
}
return nil, errors.Join(
failure,
closeErr,
)
}
if int64(len(raw)) > maximumBytes {
return nil, fmt.Errorf("%s exceeds %d bytes: %s", label, maximumBytes, filePath)
}
return &managedFileSnapshot{
contents: raw,
info: after,
mode: after.Mode().Perm(),
}, nil
}
func managedPhysicalFileExists(path, label string, maximumBytes int64) (bool, error) {
snapshot, err := readManagedFile(path, label, maximumBytes)
return snapshot != nil, err
}
func managedPhysicalDirectoryExists(path, label string) (bool, error) {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return false, fmt.Errorf("%s is not a physical directory: %s", label, path)
}
return true, nil
}
func ensureManagedDirectory(path, label string) error {
if err := os.MkdirAll(path, 0700); err != nil {
return fmt.Errorf("create %s at %s: %w", label, path, err)
}
exists, err := managedPhysicalDirectoryExists(path, label)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%s is missing: %s", label, path)
}
return nil
}
+115 -18
View File
@@ -1,22 +1,33 @@
package plugins
import "fmt"
const protectedRuntimeFileReader = `const MAX_PROTECTED_SECRET_BYTES = 64 * 1024;
const MAX_PROTECTED_CONFIG_BYTES = 512 * 1024;
const MAX_PROTECTED_LOG_BYTES = 2 * 1024 * 1024;
var protectedRuntimeFileReader = fmt.Sprintf(`const MAX_PROTECTED_SECRET_BYTES = %d;
const MAX_PROTECTED_CONFIG_BYTES = %d;
function hasRuntimeErrorCode(error, code) {
return error && typeof error === 'object' && error.code === code;
}
function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) {
const before = fs.lstatSync(filePath);
if (!before.isFile() || before.isSymbolicLink()) {
function validateProtectedMetadata(metadata, filePath, label, maxBytes) {
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error(label + ' path is not a physical file: ' + filePath);
}
if (before.size > maxBytes) {
if (metadata.size > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
if (process.platform !== 'win32' && (before.mode & 0o077) !== 0) {
if (process.platform !== 'win32' && (metadata.mode & 0o077) !== 0) {
throw new Error(label + ' permissions are too broad: ' + filePath);
}
}
function inspectProtectedFile(filePath, label, maxBytes) {
const metadata = fs.lstatSync(filePath);
validateProtectedMetadata(metadata, filePath, label, maxBytes);
return metadata;
}
function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTES) {
const before = inspectProtectedFile(filePath, label, maxBytes);
let flags = fs.constants.O_RDONLY;
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
flags |= fs.constants.O_NOFOLLOW;
@@ -24,15 +35,7 @@ function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTE
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);
}
validateProtectedMetadata(after, filePath, label, maxBytes);
if (before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(label + ' changed while opening: ' + filePath);
}
@@ -45,4 +48,98 @@ function readProtectedFile(filePath, label, maxBytes = MAX_PROTECTED_SECRET_BYTE
fs.closeSync(descriptor);
}
}
`, maxProtectedSecretBytes, maxRuntimeConfigBytes)
function openProtectedAppend(filePath, label, maxBytes) {
for (let attempt = 0; attempt < 3; attempt += 1) {
let before;
try {
before = inspectProtectedFile(filePath, label, maxBytes);
} catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
let createFlags = fs.constants.O_WRONLY | fs.constants.O_APPEND |
fs.constants.O_CREAT | fs.constants.O_EXCL;
if (process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number') {
createFlags |= fs.constants.O_NOFOLLOW;
}
try {
const descriptor = fs.openSync(filePath, createFlags, 0o600);
validateProtectedMetadata(fs.fstatSync(descriptor), filePath, label, maxBytes);
return descriptor;
} catch (createError) {
if (hasRuntimeErrorCode(createError, 'EEXIST')) continue;
throw createError;
}
}
let flags = fs.constants.O_WRONLY | fs.constants.O_APPEND;
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);
validateProtectedMetadata(after, filePath, label, maxBytes);
if (before.dev !== after.dev || before.ino !== after.ino) {
throw new Error(label + ' changed while opening: ' + filePath);
}
return descriptor;
} catch (error) {
fs.closeSync(descriptor);
throw error;
}
}
throw new Error(label + ' changed repeatedly while opening: ' + filePath);
}
function appendProtectedText(filePath, label, text, maxBytes = MAX_PROTECTED_LOG_BYTES) {
const descriptor = openProtectedAppend(filePath, label, maxBytes);
try {
const metadata = fs.fstatSync(descriptor);
const bytes = Buffer.byteLength(text, 'utf-8');
if (metadata.size + bytes > maxBytes) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
fs.writeFileSync(descriptor, text, 'utf-8');
fs.fsyncSync(descriptor);
if (process.platform !== 'win32') fs.fchmodSync(descriptor, 0o600);
} finally {
fs.closeSync(descriptor);
}
}
function writeProtectedJson(filePath, label, value) {
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded, 'utf-8') > MAX_PROTECTED_CONFIG_BYTES) {
throw new Error(label + ' exceeds the size limit: ' + filePath);
}
const temporaryPath = filePath + '.' + process.pid + '.' +
crypto.randomBytes(8).toString('hex') + '.tmp';
let descriptor;
try {
descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
fs.writeFileSync(descriptor, encoded, 'utf-8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
if (process.platform !== 'win32') fs.chmodSync(temporaryPath, 0o600);
try {
inspectProtectedFile(filePath, label, MAX_PROTECTED_CONFIG_BYTES);
} catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
}
try {
fs.renameSync(temporaryPath, filePath);
} catch (error) {
if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
inspectProtectedFile(filePath, label, MAX_PROTECTED_CONFIG_BYTES);
fs.unlinkSync(filePath);
fs.renameSync(temporaryPath, filePath);
}
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
try { fs.unlinkSync(temporaryPath); } catch (error) {
if (!hasRuntimeErrorCode(error, 'ENOENT')) throw error;
}
}
}
`
+102 -16
View File
@@ -7,6 +7,8 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
type renameFunc func(source, destination string) error
@@ -15,6 +17,7 @@ type fileChange struct {
filePath string
label string
original []byte
originalInfo os.FileInfo
originalMode os.FileMode
existed bool
next []byte
@@ -26,18 +29,19 @@ type stagedChange struct {
change fileChange
temporaryPath string
rollbackPath string
committedInfo os.FileInfo
committed bool
}
func readOptionalFile(path, label string) ([]byte, bool, error) {
raw, err := os.ReadFile(path) // #nosec G304 -- callers constrain paths to managed user configuration files.
if err == nil {
return raw, true, nil
snapshot, err := readManagedFile(path, label, maxManagedSourceBytes)
if err != nil {
return nil, false, err
}
if errors.Is(err, os.ErrNotExist) {
if snapshot == nil {
return nil, false, nil
}
return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err)
return append([]byte(nil), snapshot.contents...), true, nil
}
func prepareFileChange(filePath, label string, next []byte, mode os.FileMode) (*fileChange, error) {
@@ -59,6 +63,15 @@ func prepareSourceChange(
if !existed {
original = nil
}
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
if err != nil {
return nil, err
}
currentExists := snapshot != nil
if currentExists != existed ||
(currentExists && !bytes.Equal(snapshot.contents, original)) {
return nil, fmt.Errorf("%s changed before update: %s", label, filePath)
}
if existed && !remove && bytes.Equal(original, next) {
return nil, nil
}
@@ -66,16 +79,15 @@ func prepareSourceChange(
return nil, nil
}
originalMode := mode
var originalInfo os.FileInfo
if existed {
info, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("read file mode for %s at %s: %w", label, filePath, err)
}
originalMode = info.Mode().Perm()
originalInfo = snapshot.info
originalMode = snapshot.mode
}
return &fileChange{
filePath: filePath, label: label, original: append([]byte(nil), original...),
originalMode: originalMode, existed: existed, next: append([]byte(nil), next...),
originalInfo: originalInfo, originalMode: originalMode, existed: existed,
next: append([]byte(nil), next...),
mode: mode, remove: remove,
}, nil
}
@@ -88,6 +100,9 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error
continue
}
target := filepath.Clean(change.filePath)
if runtime.GOOS == "windows" {
target = strings.ToLower(target)
}
if targets[target] {
return fmt.Errorf("%s contains duplicate file target %s", label, change.filePath)
}
@@ -128,8 +143,8 @@ func stageChange(change fileChange) (stagedChange, error) {
return stagedChange{}, err
}
directory := filepath.Dir(change.filePath)
if err := os.MkdirAll(directory, 0700); err != nil {
return stagedChange{}, fmt.Errorf("create directory for %s at %s: %w", change.label, directory, err)
if err := ensureManagedDirectory(directory, "directory for "+change.label); err != nil {
return stagedChange{}, err
}
staged := stagedChange{change: change}
var err error
@@ -202,11 +217,14 @@ func reserveStagingPath(directory, basename, suffix string) (string, error) {
}
func assertFileUnchanged(change fileChange) error {
current, existed, err := readOptionalFile(change.filePath, change.label)
current, err := readManagedFile(change.filePath, change.label, maxManagedSourceBytes)
if err != nil {
return err
}
if existed != change.existed || !bytes.Equal(current, change.original) {
currentExists := current != nil
if currentExists != change.existed ||
(currentExists && (!bytes.Equal(current.contents, change.original) ||
!os.SameFile(current.info, change.originalInfo))) {
return fmt.Errorf("%s changed during installation: %s", change.label, change.filePath)
}
return nil
@@ -232,6 +250,55 @@ func commitChange(staged *stagedChange, rename renameFunc) error {
}
}
staged.committed = true
if !staged.change.remove {
current, err := readManagedFile(
staged.change.filePath,
staged.change.label,
maxManagedSourceBytes,
)
if err != nil {
return err
}
if current == nil || !bytes.Equal(current.contents, staged.change.next) {
return fmt.Errorf(
"%s changed immediately after commit: %s",
staged.change.label,
staged.change.filePath,
)
}
staged.committedInfo = current.info
}
return nil
}
func assertCommittedFileUnchanged(item *stagedChange) error {
current, err := readManagedFile(
item.change.filePath,
item.change.label,
maxManagedSourceBytes,
)
if err != nil {
return err
}
if item.change.remove {
if current != nil {
return fmt.Errorf(
"%s changed during transaction recovery: %s",
item.change.label,
item.change.filePath,
)
}
return nil
}
if current == nil || item.committedInfo == nil ||
!bytes.Equal(current.contents, item.change.next) ||
!os.SameFile(current.info, item.committedInfo) {
return fmt.Errorf(
"%s changed during transaction recovery: %s",
item.change.label,
item.change.filePath,
)
}
return nil
}
@@ -242,6 +309,10 @@ func rollbackChanges(staged []stagedChange, rename renameFunc) []error {
if !item.committed {
continue
}
if err := assertCommittedFileUnchanged(item); err != nil {
failures = append(failures, preserveRollbackFile(item, err))
continue
}
var err error
switch {
case item.change.remove:
@@ -252,12 +323,27 @@ func rollbackChanges(staged []stagedChange, rename renameFunc) []error {
err = removeOptionalFile(item.change.filePath)
}
if err != nil {
failures = append(failures, fmt.Errorf("restore %s at %s: %w", item.change.label, item.change.filePath, err))
failure := fmt.Errorf(
"restore %s at %s: %w",
item.change.label,
item.change.filePath,
err,
)
failures = append(failures, preserveRollbackFile(item, failure))
}
}
return failures
}
func preserveRollbackFile(item *stagedChange, cause error) error {
if item.rollbackPath == "" {
return cause
}
path := item.rollbackPath
item.rollbackPath = ""
return fmt.Errorf("%w; original content preserved at %s", cause, path)
}
func cleanupStaging(staged []stagedChange) []error {
failures := make([]error, 0)
for _, item := range staged {
@@ -0,0 +1,106 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func transactionTestChanges(t *testing.T) (string, string, []*fileChange) {
t.Helper()
directory := t.TempDir()
firstPath := filepath.Join(directory, "first.json")
secondPath := filepath.Join(directory, "second.json")
for _, item := range []struct {
path string
content string
}{
{firstPath, "first-original"},
{secondPath, "second-original"},
} {
if err := os.WriteFile(item.path, []byte(item.content), 0600); err != nil {
t.Fatalf("write transaction fixture: %v", err)
}
}
first, err := prepareFileChange(firstPath, "first test file", []byte("first-next"), 0600)
if err != nil {
t.Fatalf("prepare first transaction change: %v", err)
}
second, err := prepareFileChange(secondPath, "second test file", []byte("second-next"), 0600)
if err != nil {
t.Fatalf("prepare second transaction change: %v", err)
}
return firstPath, secondPath, []*fileChange{first, second}
}
func requirePreservedRollback(t *testing.T, directory string) {
t.Helper()
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatalf("read transaction directory: %v", err)
}
var rollbackPath string
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".rollback") {
if rollbackPath != "" {
t.Fatal("multiple rollback files remain")
}
rollbackPath = filepath.Join(directory, entry.Name())
}
}
if rollbackPath == "" {
t.Fatal("original rollback file was removed")
}
content, err := os.ReadFile(rollbackPath)
if err != nil {
t.Fatalf("read preserved rollback file: %v", err)
}
if string(content) != "first-original" {
t.Fatalf("preserved rollback content = %q", content)
}
}
func TestTransactionPreservesBackupAfterConcurrentCommittedChange(t *testing.T) {
firstPath, secondPath, changes := transactionTestChanges(t)
rename := func(source, destination string) error {
if destination == secondPath && strings.HasSuffix(source, ".tmp") {
if err := os.Remove(firstPath); err != nil {
return err
}
if err := os.WriteFile(firstPath, []byte("external"), 0600); err != nil {
return err
}
return errors.New("simulated second commit failure")
}
return os.Rename(source, destination)
}
err := writeChanges(changes, "Test transaction", rename)
if err == nil || !strings.Contains(err.Error(), "original content preserved at") {
t.Fatalf("transaction error = %v", err)
}
current, readErr := os.ReadFile(firstPath)
if readErr != nil || string(current) != "external" {
t.Fatalf("external content = %q, %v", current, readErr)
}
requirePreservedRollback(t, filepath.Dir(firstPath))
}
func TestTransactionPreservesBackupAfterRollbackRenameFailure(t *testing.T) {
firstPath, secondPath, changes := transactionTestChanges(t)
rename := func(source, destination string) error {
if destination == secondPath && strings.HasSuffix(source, ".tmp") {
return errors.New("simulated second commit failure")
}
if destination == firstPath && strings.HasSuffix(source, ".rollback") {
return errors.New("simulated rollback failure")
}
return os.Rename(source, destination)
}
err := writeChanges(changes, "Test transaction", rename)
if err == nil || !strings.Contains(err.Error(), "original content preserved at") {
t.Fatalf("transaction error = %v", err)
}
requirePreservedRollback(t, filepath.Dir(firstPath))
}