Split the monolithic transaction into focused modules: append-only crash-recoverable journal under ~/.elydora/transactions with owner tokens and per-transaction namespaces, staged atomic publication (stage .pending, fsync, rename, fsync), platform-specific atomic replacement (MoveFileEx, renameat2, renamex_np), private-directory hardening per OS, lock-guarded recovery, and workspace cleanup that removes only transaction-created empty directories. Add the Kiro IDE 1.0 plugin: strict v1 hook schema with one enabled PreToolUse guard and PostToolUse audit pair bound to the active workspace, agent-to-workspace identity binding in runtime config, and exact migration of the legacy elydora-audit.kiro.hook contract. Gates: go test ./... and -race pass, go vet clean, govulncheck reports zero reachable vulnerabilities, git diff --check clean.
381 lines
14 KiB
Go
381 lines
14 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestKiroIdeRollbackRestoresRuntimeWorkspaceAndLegacySources(t *testing.T) {
|
|
original := `{"version":"v1","owner":"workspace","hooks":[]}`
|
|
fixture := prepareKiroIdeFixture(t, &original, nil)
|
|
writeKiroIdeJSON(
|
|
t,
|
|
fixture.legacyPath,
|
|
legacyKiroIdeGoDocument(fixture, kiroIdeTestAgentID),
|
|
)
|
|
fixture.plugin.rename = func(source, destination string) error {
|
|
if sameKiroIdePath(source, fixture.legacyPath) &&
|
|
strings.HasSuffix(destination, ".rollback") {
|
|
return errors.New("injected Kiro IDE legacy cleanup failure")
|
|
}
|
|
return os.Rename(source, destination)
|
|
}
|
|
err := fixture.plugin.Install(fixture.config)
|
|
if err == nil || !strings.Contains(err.Error(), "injected Kiro IDE legacy cleanup failure") {
|
|
t.Fatalf("install error = %v", err)
|
|
}
|
|
actual, readErr := os.ReadFile(fixture.configPath)
|
|
if readErr != nil || string(actual) != original {
|
|
t.Fatalf("workspace source after rollback = %q, %v", actual, readErr)
|
|
}
|
|
if _, err := os.Lstat(fixture.legacyPath); err != nil {
|
|
t.Fatalf("legacy source was not restored: %v", err)
|
|
}
|
|
assertNoKiroIdeRuntimeFiles(t, fixture)
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.workspace)
|
|
}
|
|
|
|
func TestKiroIdePreparedInstallProtectsUnchangedSources(t *testing.T) {
|
|
fixture := prepareKiroIdeFixture(t, nil, nil)
|
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
t.Fatalf("install Kiro IDE hooks: %v", err)
|
|
}
|
|
if err := os.WriteFile(fixture.guardPath, []byte("repair me\n"), 0700); err != nil {
|
|
t.Fatalf("damage guard runtime: %v", err)
|
|
}
|
|
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
|
concurrent := `{"version":"v1","owner":"concurrent","hooks":[]}`
|
|
if err := os.WriteFile(fixture.configPath, []byte(concurrent), 0600); err != nil {
|
|
t.Fatalf("write concurrent workspace source: %v", err)
|
|
}
|
|
err := commitKiroIdeInstallation(prepared, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") {
|
|
t.Fatalf("prepared install error = %v", err)
|
|
}
|
|
actual, readErr := os.ReadFile(fixture.configPath)
|
|
if readErr != nil || string(actual) != concurrent {
|
|
t.Fatalf("concurrent workspace source changed: %q, %v", actual, readErr)
|
|
}
|
|
guard, readErr := os.ReadFile(fixture.guardPath)
|
|
if readErr != nil || string(guard) != "repair me\n" {
|
|
t.Fatalf("runtime changed after source race: %q, %v", guard, readErr)
|
|
}
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.workspace)
|
|
}
|
|
|
|
func TestKiroIdeInstallRepairsRuntimeModesOnPOSIX(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX file modes are unavailable on Windows")
|
|
}
|
|
fixture := prepareKiroIdeFixture(t, nil, nil)
|
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
t.Fatalf("install Kiro IDE hooks: %v", err)
|
|
}
|
|
files := []struct {
|
|
path string
|
|
drift os.FileMode
|
|
want os.FileMode
|
|
}{
|
|
{fixture.runtimeConfig, 0644, 0600},
|
|
{fixture.privateKey, 0644, 0600},
|
|
{fixture.guardPath, 0600, 0700},
|
|
{fixture.auditPath, 0600, 0700},
|
|
}
|
|
for _, file := range files {
|
|
if err := os.Chmod(file.path, file.drift); err != nil {
|
|
t.Fatalf("drift runtime mode at %s: %v", file.path, err)
|
|
}
|
|
}
|
|
status, err := fixture.plugin.Status()
|
|
if err != nil || status.Installed || !status.HookConfigured {
|
|
t.Fatalf("mode-drifted Kiro IDE status = %#v, %v", status, err)
|
|
}
|
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
t.Fatalf("repair Kiro IDE runtime modes: %v", err)
|
|
}
|
|
for _, file := range files {
|
|
info, err := os.Stat(file.path)
|
|
if err != nil || info.Mode().Perm() != file.want {
|
|
t.Fatalf("runtime mode at %s = %v, %v", file.path, infoMode(info), err)
|
|
}
|
|
}
|
|
status, err = fixture.plugin.Status()
|
|
if err != nil || !status.Installed || !status.HookConfigured {
|
|
t.Fatalf("repaired Kiro IDE status = %#v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestKiroIdePreparedInstallDetectsConcurrentModeChangesOnPOSIX(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX file modes are unavailable on Windows")
|
|
}
|
|
t.Run("changed private key", func(t *testing.T) {
|
|
fixture := prepareKiroIdeFixture(t, nil, nil)
|
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
t.Fatalf("install Kiro IDE hooks: %v", err)
|
|
}
|
|
fixture.config.PrivateKey = base64.RawURLEncoding.EncodeToString(
|
|
bytes.Repeat([]byte{12}, 32),
|
|
)
|
|
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
|
if err := os.Chmod(fixture.privateKey, 0644); err != nil {
|
|
t.Fatalf("concurrently chmod private key: %v", err)
|
|
}
|
|
err := commitKiroIdeInstallation(prepared, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "private key changed during installation") {
|
|
t.Fatalf("concurrent private key mode error = %v", err)
|
|
}
|
|
key, readErr := os.ReadFile(fixture.privateKey)
|
|
if readErr != nil || string(key) != kiroIdePrivateKey {
|
|
t.Fatalf("private key changed after mode race: %q, %v", key, readErr)
|
|
}
|
|
info, statErr := os.Stat(fixture.privateKey)
|
|
if statErr != nil || info.Mode().Perm() != 0644 {
|
|
t.Fatalf("private key mode after race = %v, %v", infoMode(info), statErr)
|
|
}
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
|
})
|
|
t.Run("unchanged runtime config", func(t *testing.T) {
|
|
fixture := prepareKiroIdeFixture(t, nil, nil)
|
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
t.Fatalf("install Kiro IDE hooks: %v", err)
|
|
}
|
|
if err := os.WriteFile(fixture.guardPath, []byte("repair me\n"), 0700); err != nil {
|
|
t.Fatalf("damage guard runtime: %v", err)
|
|
}
|
|
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
|
if err := os.Chmod(fixture.runtimeConfig, 0644); err != nil {
|
|
t.Fatalf("concurrently chmod runtime config: %v", err)
|
|
}
|
|
err := commitKiroIdeInstallation(prepared, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "runtime config changed during Kiro IDE installation") {
|
|
t.Fatalf("concurrent runtime config mode error = %v", err)
|
|
}
|
|
guard, readErr := os.ReadFile(fixture.guardPath)
|
|
if readErr != nil || string(guard) != "repair me\n" {
|
|
t.Fatalf("guard changed after mode race: %q, %v", guard, readErr)
|
|
}
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
|
})
|
|
}
|
|
|
|
func infoMode(info os.FileInfo) os.FileMode {
|
|
if info == nil {
|
|
return 0
|
|
}
|
|
return info.Mode().Perm()
|
|
}
|
|
|
|
func TestKiroIdePreparedInstallRejectsFreshWorkspaceDirectoryReplacement(t *testing.T) {
|
|
fixture := prepareKiroIdeFixture(t, nil, nil)
|
|
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
|
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-workspace")
|
|
if err := os.MkdirAll(external, 0700); err != nil {
|
|
t.Fatalf("create external workspace directory: %v", err)
|
|
}
|
|
if err := os.Symlink(external, filepath.Join(fixture.workspace, ".kiro")); err != nil {
|
|
t.Skipf("directory symbolic links unavailable: %v", err)
|
|
}
|
|
err := commitKiroIdeInstallation(prepared, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") {
|
|
t.Fatalf("workspace replacement error = %v", err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(external, "hooks", kiroIdeConfigFile)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("external workspace received hooks: %v", err)
|
|
}
|
|
assertNoKiroIdeRuntimeFiles(t, fixture)
|
|
}
|
|
|
|
func TestKiroIdePreparedInstallRejectsFreshRuntimeDirectoryReplacements(t *testing.T) {
|
|
for _, target := range []string{"root", "agent"} {
|
|
t.Run(target, func(t *testing.T) {
|
|
fixture := prepareKiroIdeFixture(t, nil, nil)
|
|
runtimeRoot := filepath.Join(fixture.homeDir, ".elydora")
|
|
if target == "agent" {
|
|
if err := os.Mkdir(runtimeRoot, 0700); err != nil {
|
|
t.Fatalf("create physical runtime root: %v", err)
|
|
}
|
|
}
|
|
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
|
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-runtime-"+target)
|
|
if err := os.MkdirAll(external, 0700); err != nil {
|
|
t.Fatalf("create external runtime directory: %v", err)
|
|
}
|
|
linkPath := runtimeRoot
|
|
if target == "agent" {
|
|
linkPath = fixture.agentDir
|
|
}
|
|
if err := os.Symlink(external, linkPath); err != nil {
|
|
t.Skipf("directory symbolic links unavailable: %v", err)
|
|
}
|
|
err := commitKiroIdeInstallation(prepared, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") {
|
|
t.Fatalf("runtime %s replacement error = %v", target, err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(external, "private.key")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("external runtime received private key: %v", err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(external, kiroIdeGuardScript)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("external runtime received guard: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKiroIdePreparedInstallRejectsExistingWorkspaceIdentityReplacement(t *testing.T) {
|
|
source := `{"version":"v1","owner":"workspace","hooks":[]}`
|
|
fixture := prepareKiroIdeFixture(t, &source, nil)
|
|
prepared := prepareKiroIdeInstallForTest(t, fixture)
|
|
kiroDirectory := filepath.Join(fixture.workspace, ".kiro")
|
|
backup := kiroDirectory + ".original"
|
|
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-existing-workspace")
|
|
if err := os.MkdirAll(filepath.Join(external, "hooks"), 0700); err != nil {
|
|
t.Fatalf("create external hooks directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(
|
|
filepath.Join(external, "hooks", kiroIdeConfigFile),
|
|
[]byte(source),
|
|
0600,
|
|
); err != nil {
|
|
t.Fatalf("write external matching source: %v", err)
|
|
}
|
|
if err := os.Rename(kiroDirectory, backup); err != nil {
|
|
t.Fatalf("move physical Kiro directory: %v", err)
|
|
}
|
|
if err := os.Symlink(external, kiroDirectory); err != nil {
|
|
_ = os.Rename(backup, kiroDirectory)
|
|
t.Skipf("directory symbolic links unavailable: %v", err)
|
|
}
|
|
err := commitKiroIdeInstallation(prepared, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "changed during Kiro IDE installation") {
|
|
t.Fatalf("existing workspace replacement error = %v", err)
|
|
}
|
|
actual, readErr := os.ReadFile(filepath.Join(external, "hooks", kiroIdeConfigFile))
|
|
if readErr != nil || string(actual) != source {
|
|
t.Fatalf("external matching source changed: %q, %v", actual, readErr)
|
|
}
|
|
assertNoKiroIdeRuntimeFiles(t, fixture)
|
|
}
|
|
|
|
func TestKiroIdeRejectsFileAndDirectoryLinksBeforeRuntimeWrites(t *testing.T) {
|
|
for _, kind := range []string{"workspace-config", "workspace-directory", "runtime-directory"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
fixture := prepareKiroIdeFixture(t, nil, nil)
|
|
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-"+kind)
|
|
if kind == "workspace-config" {
|
|
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil {
|
|
t.Fatalf("create hooks directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(external, []byte(`{"version":"v1","hooks":[]}`), 0600); err != nil {
|
|
t.Fatalf("write external config: %v", err)
|
|
}
|
|
if err := os.Symlink(external, fixture.configPath); err != nil {
|
|
t.Skipf("file symbolic links unavailable: %v", err)
|
|
}
|
|
} else {
|
|
if err := os.MkdirAll(external, 0700); err != nil {
|
|
t.Fatalf("create external directory: %v", err)
|
|
}
|
|
linkPath := filepath.Join(fixture.workspace, ".kiro")
|
|
if kind == "runtime-directory" {
|
|
linkPath = filepath.Join(fixture.homeDir, ".elydora")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
|
|
t.Fatalf("create link parent: %v", err)
|
|
}
|
|
if err := os.Symlink(external, linkPath); err != nil {
|
|
t.Skipf("directory symbolic links unavailable: %v", err)
|
|
}
|
|
}
|
|
err := fixture.plugin.Install(fixture.config)
|
|
if err == nil || !strings.Contains(err.Error(), "physical") {
|
|
t.Fatalf("linked %s error = %v", kind, err)
|
|
}
|
|
assertNoKiroIdeRuntimeFiles(t, fixture)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKiroIdeInstallSurvivesAdversarialWorkspaceLinkSwap(t *testing.T) {
|
|
fixture := prepareKiroIdeFixture(t, nil, nil)
|
|
kiroDirectory := filepath.Join(fixture.workspace, ".kiro")
|
|
external := filepath.Join(filepath.Dir(fixture.homeDir), "external-link-swap")
|
|
probe := filepath.Join(filepath.Dir(fixture.homeDir), "link-probe")
|
|
if err := os.MkdirAll(external, 0700); err != nil {
|
|
t.Fatalf("create external directory: %v", err)
|
|
}
|
|
if err := os.Symlink(external, probe); err != nil {
|
|
t.Skipf("directory symbolic links unavailable: %v", err)
|
|
}
|
|
if err := os.Remove(probe); err != nil {
|
|
t.Fatalf("remove link probe: %v", err)
|
|
}
|
|
swapped := false
|
|
fixture.plugin.rename = func(source, destination string) error {
|
|
if !swapped && sameKiroIdePath(destination, fixture.configPath) &&
|
|
strings.HasSuffix(source, ".tmp") {
|
|
swapped = true
|
|
backup := kiroDirectory + ".original"
|
|
if err := os.Rename(kiroDirectory, backup); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Symlink(external, kiroDirectory); err != nil {
|
|
_ = os.Rename(backup, kiroDirectory)
|
|
return err
|
|
}
|
|
renameErr := os.Rename(source, destination)
|
|
_ = os.Remove(kiroDirectory)
|
|
restoreErr := os.Rename(backup, kiroDirectory)
|
|
return errors.Join(renameErr, restoreErr)
|
|
}
|
|
return os.Rename(source, destination)
|
|
}
|
|
err := fixture.plugin.Install(fixture.config)
|
|
if err == nil || !swapped {
|
|
t.Fatalf("link-swap install error = %v, swapped=%v", err, swapped)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(external, "hooks", kiroIdeConfigFile)); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("external hook was written during link swap: %v", err)
|
|
}
|
|
assertNoKiroIdeRuntimeFiles(t, fixture)
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.workspace)
|
|
}
|
|
|
|
func TestKiroIdeInstallDetectsConcurrentSourceIdentityReplacement(t *testing.T) {
|
|
source := `{"version":"v1","owner":"original","hooks":[]}`
|
|
fixture := prepareKiroIdeFixture(t, &source, nil)
|
|
mutated := false
|
|
fixture.plugin.rename = func(stagedPath, destination string) error {
|
|
if !mutated && strings.HasSuffix(stagedPath, ".tmp") {
|
|
mutated = true
|
|
external := fixture.configPath + ".external"
|
|
if err := os.Rename(fixture.configPath, external); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(fixture.configPath, []byte(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) != source {
|
|
t.Fatalf("replacement source changed: %q, %v", actual, readErr)
|
|
}
|
|
assertNoKiroIdeRuntimeFiles(t, fixture)
|
|
assertNoKiroIdeTransactionArtifacts(t, fixture.homeDir)
|
|
}
|