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