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