fix(go): sync transactional Codex hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 03:28:14 -04:00
1 parent 1d2e08de97
commit e4d463d085
17 files changed
+2558 -507

No files matched your search

+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
}
@@ -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
}
@@ -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
}
@@ -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)
}
})
}
}
@@ -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) {
@@ -17,6 +17,7 @@ const guardTestAgentID = "agent 1"
func writeGuardFixture(t *testing.T, status string) (string, string, *httptest.Server) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.EscapedPath() != "/v1/agents/agent%201" {
t.Errorf("request path = %q, want encoded agent path", r.URL.EscapedPath())
@@ -26,6 +27,7 @@ func writeGuardFixture(t *testing.T, status string) (string, string, *httptest.S
"agent": map[string]string{"status": status},
})
}))
homeDir := t.TempDir()
agentDir := filepath.Join(homeDir, ".elydora", guardTestAgentID)
if err := os.MkdirAll(agentDir, 0755); err != nil {
@@ -41,21 +43,25 @@ func writeGuardFixture(t *testing.T, status string) (string, string, *httptest.S
if err := os.WriteFile(filepath.Join(agentDir, "config.json"), config, 0600); err != nil {
t.Fatalf("write config: %v", err)
}
scriptPath := filepath.Join(homeDir, "guard.js")
if err := os.WriteFile(scriptPath, []byte(GenerateGuardScript("claudecode", guardTestAgentID)), 0755); err != nil {
t.Fatalf("write guard script: %v", err)
}
return scriptPath, homeDir, server
}
func runGuard(t *testing.T, scriptPath string, homeDir string) (int, string) {
t.Helper()
nodeBinary, err := exec.LookPath("node")
if err != nil {
t.Skip("Node.js is required to execute generated hook scripts")
}
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()
@@ -72,6 +78,7 @@ func runGuard(t *testing.T, scriptPath string, homeDir string) (int, string) {
func TestGuardBlocksRemoteFrozenAgent(t *testing.T) {
scriptPath, homeDir, server := writeGuardFixture(t, "frozen")
defer server.Close()
exitCode, stderr := runGuard(t, scriptPath, homeDir)
if exitCode != 2 {
t.Fatalf("exit code = %d, want 2; stderr = %q", exitCode, stderr)
@@ -84,6 +91,7 @@ func TestGuardBlocksRemoteFrozenAgent(t *testing.T) {
func TestGuardAllowsActiveAgent(t *testing.T) {
scriptPath, homeDir, server := writeGuardFixture(t, "active")
defer server.Close()
exitCode, stderr := runGuard(t, scriptPath, homeDir)
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0; stderr = %q", exitCode, stderr)
@@ -93,9 +101,10 @@ func TestGuardAllowsActiveAgent(t *testing.T) {
}
}
func TestGuardBlocksCachedFrozenAgent(t *testing.T) {
func TestGuardRejectsFutureCachedStatus(t *testing.T) {
scriptPath, homeDir, server := writeGuardFixture(t, "active")
defer server.Close()
cache := map[string]any{
"status": "frozen",
"cached_at": time.Now().Add(time.Minute).UnixMilli(),
@@ -108,11 +117,12 @@ func TestGuardBlocksCachedFrozenAgent(t *testing.T) {
if err := os.WriteFile(cachePath, cacheJSON, 0600); err != nil {
t.Fatalf("write cache: %v", err)
}
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)
}
}
@@ -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
}
@@ -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))
}