fix: make Codex hooks transactional
This commit is contained in:
1 parent
415a884efe
commit
f0e4694da2
17 files changed
+2550
-506
No files matched your search
@@ -1,20 +1,51 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
codexAgentKey = "codex"
|
||||
codexConfigFile = "hooks.json"
|
||||
codexGuardScript = "guard.js"
|
||||
codexAuditScript = "hook.js"
|
||||
codexHookTimeout = float64(10)
|
||||
codexOwnedDescription = "Elydora audit and freeze enforcement"
|
||||
codexGuardStatusMessage = "Checking Elydora agent state"
|
||||
codexAuditStatusMessage = "Recording Elydora tool use"
|
||||
codexPOSIXApostrophe = `'"'"'`
|
||||
)
|
||||
|
||||
type codexHooks map[string][]map[string]any
|
||||
|
||||
type codexDocument struct {
|
||||
exists bool
|
||||
filePath string
|
||||
root map[string]any
|
||||
hooks codexHooks
|
||||
raw []byte
|
||||
}
|
||||
|
||||
type codexRenderedDocument struct {
|
||||
document *codexDocument
|
||||
changed bool
|
||||
next []byte
|
||||
remove bool
|
||||
}
|
||||
|
||||
type codexRuntimeContract struct {
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
|
||||
func cloneCodexObject(value map[string]any) map[string]any {
|
||||
clone := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
@@ -23,230 +54,280 @@ func cloneCodexObject(value map[string]any) map[string]any {
|
||||
return clone
|
||||
}
|
||||
|
||||
func codexHooksObject(settings map[string]any) (map[string]any, error) {
|
||||
value, exists := settings["hooks"]
|
||||
if !exists {
|
||||
return map[string]any{}, nil
|
||||
func cloneCodexHooks(source codexHooks) codexHooks {
|
||||
clone := make(codexHooks, len(source))
|
||||
for event, groups := range source {
|
||||
clone[event] = append([]map[string]any(nil), groups...)
|
||||
}
|
||||
hooks, ok := value.(map[string]any)
|
||||
if !ok || hooks == nil {
|
||||
return nil, fmt.Errorf(`Codex hooks config field "hooks" must be an object`)
|
||||
}
|
||||
return cloneCodexObject(hooks), nil
|
||||
return clone
|
||||
}
|
||||
|
||||
func codexEventGroups(hooks map[string]any, event string) ([]map[string]any, error) {
|
||||
value, exists := hooks[event]
|
||||
if !exists {
|
||||
return []map[string]any{}, nil
|
||||
func sameCodexPath(left, right string) bool {
|
||||
left = filepath.Clean(left)
|
||||
right = filepath.Clean(right)
|
||||
if absolute, err := filepath.Abs(left); err == nil {
|
||||
left = absolute
|
||||
}
|
||||
groups, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(`Codex hooks config field "hooks.%s" must be an array of objects`, event)
|
||||
if absolute, err := filepath.Abs(right); err == nil {
|
||||
right = absolute
|
||||
}
|
||||
objects := make([]map[string]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
object, ok := group.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, fmt.Errorf(`Codex hooks config field "hooks.%s" must be an array of objects`, event)
|
||||
}
|
||||
objects = append(objects, object)
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func codexGroupHandlers(group map[string]any) ([]map[string]any, error) {
|
||||
value, exists := group["hooks"]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
|
||||
}
|
||||
handlers, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
|
||||
}
|
||||
objects := make([]map[string]any, 0, len(handlers))
|
||||
for _, handler := range handlers {
|
||||
object, ok := handler.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, fmt.Errorf("Codex hook matcher group must contain a hooks array")
|
||||
}
|
||||
objects = append(objects, object)
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func normalizeCodexPath(value string) string {
|
||||
normalized := filepath.ToSlash(value)
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(normalized)
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return normalized
|
||||
return left == right
|
||||
}
|
||||
|
||||
func isManagedCodexCommand(command any, scriptName, agentID string) bool {
|
||||
text, ok := command.(string)
|
||||
func sameCodexAgentID(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func readCodexHooks(value any, label string) (codexHooks, error) {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
||||
}
|
||||
normalized := normalizeCodexPath(text)
|
||||
if !strings.Contains(normalized, "/.elydora/") {
|
||||
return false
|
||||
}
|
||||
suffix := "/" + normalizeCodexPath(scriptName)
|
||||
if agentID == "" {
|
||||
return codexCommandEndsWith(normalized, suffix)
|
||||
}
|
||||
expected := normalizeCodexPath(filepath.Join(".elydora", agentID, scriptName))
|
||||
return codexCommandEndsWith(normalized, "/"+expected)
|
||||
}
|
||||
|
||||
func codexCommandEndsWith(command, scriptPath string) bool {
|
||||
trimmed := strings.TrimSpace(command)
|
||||
return strings.HasSuffix(trimmed, scriptPath) ||
|
||||
strings.HasSuffix(trimmed, scriptPath+`'`) ||
|
||||
strings.HasSuffix(trimmed, scriptPath+`"`)
|
||||
}
|
||||
|
||||
func isElydoraCodexHandler(handler map[string]any, agentID string) bool {
|
||||
var scriptName string
|
||||
switch handler["statusMessage"] {
|
||||
case codexGuardStatusMessage:
|
||||
scriptName = "guard.js"
|
||||
case codexAuditStatusMessage:
|
||||
scriptName = "hook.js"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return isManagedCodexCommand(handler["command"], scriptName, agentID) ||
|
||||
isManagedCodexCommand(handler["commandWindows"], scriptName, agentID)
|
||||
}
|
||||
|
||||
func withoutCodexHandlers(groups []map[string]any, agentID string) ([]map[string]any, bool, error) {
|
||||
filteredGroups := make([]map[string]any, 0, len(groups))
|
||||
changed := false
|
||||
for _, group := range groups {
|
||||
handlers, err := codexGroupHandlers(group)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
hooks := make(codexHooks, len(object))
|
||||
for event, candidate := range object {
|
||||
values, ok := candidate.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
|
||||
}
|
||||
filteredHandlers := make([]any, 0, len(handlers))
|
||||
removed := false
|
||||
for _, handler := range handlers {
|
||||
if isElydoraCodexHandler(handler, agentID) {
|
||||
changed = true
|
||||
removed = true
|
||||
continue
|
||||
groups := make([]map[string]any, 0, len(values))
|
||||
for groupIndex, value := range values {
|
||||
group, ok := value.(map[string]any)
|
||||
if !ok || group == nil {
|
||||
return nil, fmt.Errorf(
|
||||
`%s matcher group hooks.%s[%d] must be an object`,
|
||||
label, event, groupIndex,
|
||||
)
|
||||
}
|
||||
filteredHandlers = append(filteredHandlers, handler)
|
||||
handlerValues, ok := group["hooks"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
`%s matcher group hooks.%s[%d] must contain a hooks array`,
|
||||
label, event, groupIndex,
|
||||
)
|
||||
}
|
||||
for handlerIndex, handler := range handlerValues {
|
||||
if object, ok := handler.(map[string]any); !ok || object == nil {
|
||||
return nil, fmt.Errorf(
|
||||
`%s handler hooks.%s[%d].hooks[%d] must be an object`,
|
||||
label, event, groupIndex, handlerIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
groups = append(groups, group)
|
||||
}
|
||||
if len(filteredHandlers) == 0 && removed {
|
||||
continue
|
||||
}
|
||||
nextGroup := cloneCodexObject(group)
|
||||
nextGroup["hooks"] = filteredHandlers
|
||||
filteredGroups = append(filteredGroups, nextGroup)
|
||||
hooks[event] = groups
|
||||
}
|
||||
return filteredGroups, changed, nil
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
func findCodexHandler(hooks map[string]any, event, status string) (map[string]any, error) {
|
||||
groups, err := codexEventGroups(hooks, event)
|
||||
func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) {
|
||||
if !json.Valid(source) {
|
||||
var value any
|
||||
if err := json.Unmarshal(source, &value); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", label, err)
|
||||
}
|
||||
}
|
||||
return decodeJSONCObject(source, label, false)
|
||||
}
|
||||
|
||||
func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) {
|
||||
label := fmt.Sprintf("Codex user hooks at %s", filePath)
|
||||
root, err := decodeStrictJSONObject(raw, label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, group := range groups {
|
||||
handlers, err := codexGroupHandlers(group)
|
||||
hooks := codexHooks{}
|
||||
if value, exists := root["hooks"]; exists {
|
||||
hooks, err = readCodexHooks(value, label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, handler := range handlers {
|
||||
if handler["statusMessage"] == status && isElydoraCodexHandler(handler, "") {
|
||||
return handler, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
return &codexDocument{
|
||||
exists: true, filePath: filePath, root: root,
|
||||
hooks: hooks, raw: append([]byte(nil), raw...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func codexSettingsOwned(settings, hooks map[string]any) bool {
|
||||
for key := range settings {
|
||||
func createCodexDocument(filePath string) *codexDocument {
|
||||
return &codexDocument{
|
||||
filePath: filePath,
|
||||
root: map[string]any{"description": codexOwnedDescription},
|
||||
hooks: codexHooks{},
|
||||
}
|
||||
}
|
||||
|
||||
func exactCodexMatcherGroup(group map[string]any) bool {
|
||||
return len(group) == 2 && group["matcher"] == "*"
|
||||
}
|
||||
|
||||
func removeCodexGroups(
|
||||
groups []map[string]any,
|
||||
scriptName string,
|
||||
status string,
|
||||
agentID string,
|
||||
) []map[string]any {
|
||||
result := make([]map[string]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
handlers := group["hooks"].([]any)
|
||||
kept := make([]any, 0, len(handlers))
|
||||
for _, value := range handlers {
|
||||
handler := value.(map[string]any)
|
||||
managedID, managed := codexManagedAgentID(handler, scriptName, status)
|
||||
if managed && (agentID == "" || sameCodexAgentID(managedID, agentID)) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, handler)
|
||||
}
|
||||
if len(kept) > 0 || !exactCodexMatcherGroup(group) {
|
||||
next := cloneCodexObject(group)
|
||||
next["hooks"] = kept
|
||||
result = append(result, next)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removeManagedCodexHooks(hooks codexHooks, agentID string) codexHooks {
|
||||
next := cloneCodexHooks(hooks)
|
||||
for _, contract := range []struct{ event, script, status string }{
|
||||
{"PreToolUse", codexGuardScript, codexGuardStatusMessage},
|
||||
{"PostToolUse", codexAuditScript, codexAuditStatusMessage},
|
||||
} {
|
||||
groups := removeCodexGroups(
|
||||
next[contract.event], contract.script, contract.status, agentID,
|
||||
)
|
||||
if len(groups) == 0 {
|
||||
delete(next, contract.event)
|
||||
} else {
|
||||
next[contract.event] = groups
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func entirelyManagedCodexDocument(document *codexDocument) bool {
|
||||
if !document.exists || document.root["description"] != codexOwnedDescription ||
|
||||
len(document.hooks) == 0 {
|
||||
return false
|
||||
}
|
||||
for key := range document.root {
|
||||
if key != "description" && key != "hooks" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if settings["description"] != codexOwnedDescription {
|
||||
return false
|
||||
}
|
||||
for event, value := range hooks {
|
||||
if event != "PreToolUse" && event != "PostToolUse" {
|
||||
handlerCount := 0
|
||||
for event, groups := range document.hooks {
|
||||
contract := map[string][2]string{
|
||||
"PreToolUse": {codexGuardScript, codexGuardStatusMessage},
|
||||
"PostToolUse": {codexAuditScript, codexAuditStatusMessage},
|
||||
}[event]
|
||||
if contract[0] == "" || len(groups) == 0 {
|
||||
return false
|
||||
}
|
||||
groups, ok := value.([]map[string]any)
|
||||
if !ok || len(groups) != 0 {
|
||||
return false
|
||||
for _, group := range groups {
|
||||
handlers := group["hooks"].([]any)
|
||||
if !exactCodexMatcherGroup(group) || len(handlers) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, value := range handlers {
|
||||
if _, managed := codexManagedAgentID(
|
||||
value.(map[string]any), contract[0], contract[1],
|
||||
); !managed {
|
||||
return false
|
||||
}
|
||||
handlerCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return handlerCount > 0
|
||||
}
|
||||
|
||||
func codexCommandReferences(handler map[string]any, scriptPath string) bool {
|
||||
expected := normalizeCodexPath(scriptPath)
|
||||
for _, key := range []string{"command", "commandWindows"} {
|
||||
command, ok := handler[key].(string)
|
||||
if ok && codexCommandEndsWith(normalizeCodexPath(command), expected) {
|
||||
return true
|
||||
}
|
||||
func renderCodexDocument(
|
||||
document *codexDocument,
|
||||
hooks codexHooks,
|
||||
) (*codexRenderedDocument, error) {
|
||||
if !document.exists && len(hooks) == 0 {
|
||||
return &codexRenderedDocument{document: document}, nil
|
||||
}
|
||||
return false
|
||||
if document.exists && reflect.DeepEqual(hooks, document.hooks) {
|
||||
return &codexRenderedDocument{document: document}, nil
|
||||
}
|
||||
if len(hooks) == 0 && entirelyManagedCodexDocument(document) {
|
||||
return &codexRenderedDocument{document: document, changed: true, remove: true}, nil
|
||||
}
|
||||
root := cloneCodexObject(document.root)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
} else {
|
||||
root["hooks"] = hooks
|
||||
}
|
||||
next, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next = append(next, '\n')
|
||||
return &codexRenderedDocument{
|
||||
document: document,
|
||||
changed: !document.exists || !bytes.Equal(next, document.raw),
|
||||
next: next,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func codexRuntimeScriptsExist(guard, audit map[string]any) (bool, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve home directory: %w", err)
|
||||
func managedCodexIDs(
|
||||
groups []map[string]any,
|
||||
scriptName string,
|
||||
status string,
|
||||
) map[string]string {
|
||||
result := map[string]string{}
|
||||
for _, group := range groups {
|
||||
if !exactCodexMatcherGroup(group) {
|
||||
continue
|
||||
}
|
||||
for _, value := range group["hooks"].([]any) {
|
||||
agentID, managed := codexManagedAgentID(value.(map[string]any), scriptName, status)
|
||||
if !managed {
|
||||
continue
|
||||
}
|
||||
key := agentID
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(agentID)
|
||||
}
|
||||
result[key] = agentID
|
||||
}
|
||||
}
|
||||
root := filepath.Join(home, ".elydora")
|
||||
entries, err := os.ReadDir(root)
|
||||
return result
|
||||
}
|
||||
|
||||
func codexRuntimeContracts(hooks codexHooks) ([]codexRuntimeContract, error) {
|
||||
guards := managedCodexIDs(hooks["PreToolUse"], codexGuardScript, codexGuardStatusMessage)
|
||||
audits := managedCodexIDs(hooks["PostToolUse"], codexAuditScript, codexAuditStatusMessage)
|
||||
keys := make([]string, 0, len(guards))
|
||||
for key := range guards {
|
||||
if _, exists := audits[key]; exists {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
root, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read Elydora runtime directory at %s: %w", root, err)
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
agentDir := filepath.Join(root, entry.Name())
|
||||
guardPath := filepath.Join(agentDir, "guard.js")
|
||||
hookPath := filepath.Join(agentDir, "hook.js")
|
||||
if !codexCommandReferences(guard, guardPath) || !codexCommandReferences(audit, hookPath) {
|
||||
continue
|
||||
}
|
||||
configPath := filepath.Join(agentDir, "config.json")
|
||||
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
agentName, ok := config["agent_name"].(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf(`Elydora runtime config at %s field "agent_name" must be a string`, configPath)
|
||||
}
|
||||
if agentName != codexAgentKey {
|
||||
continue
|
||||
}
|
||||
guardExists, err := regularFileExists(guardPath, "Elydora guard runtime")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
hookExists, err := regularFileExists(hookPath, "Elydora audit runtime")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return guardExists && hookExists, nil
|
||||
contracts := make([]codexRuntimeContract, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
agentID := guards[key]
|
||||
directory := filepath.Join(root, agentID)
|
||||
contracts = append(contracts, codexRuntimeContract{
|
||||
agentID: agentID,
|
||||
guardPath: filepath.Join(directory, codexGuardScript),
|
||||
auditPath: filepath.Join(directory, codexAuditScript),
|
||||
})
|
||||
}
|
||||
return false, nil
|
||||
return contracts, nil
|
||||
}
|
||||
Reference in new issue
Block a user