fix: make Codex hooks transactional

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

No files matched your search

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