diff --git a/policies/default/caller_allowlist.rego b/policies/default/caller_allowlist.rego new file mode 100644 index 0000000..7948b29 --- /dev/null +++ b/policies/default/caller_allowlist.rego @@ -0,0 +1,24 @@ +# caller_allowlist.rego — optional policy for restricting chain-writing callers. +# +# Activate by editing the set below and removing (or overriding) the +# `default allow := true` rule in writ.rego. +# +# writ passes input.caller_id on every gate evaluation. Only callers in the +# set below will be permitted to execute LLM calls (and write to the chain). +# +# Example (uncomment to activate): +# +# package writ.gate +# +# import rego.v1 +# +# _allowed_callers := {"my-agent-v1", "my-agent-v2"} +# +# deny if { +# count(_allowed_callers) > 0 +# not _allowed_callers[input.caller_id] +# } +# +# allow if { +# not deny +# } diff --git a/protect_linux.go b/protect_linux.go new file mode 100644 index 0000000..3506d34 --- /dev/null +++ b/protect_linux.go @@ -0,0 +1,38 @@ +//go:build linux + +package writ + +import ( + "os" + "syscall" + "unsafe" //#nosec G103 -- ioctl for FS_APPEND_FL; unsafe.Pointer is the only available interface +) + +// FS ioctl constants for 64-bit Linux (_IOR/'f'/1/sizeof(long) and _IOW/'f'/2/sizeof(long)). +const ( + _fsIOCGetFlags = uintptr(0x80086601) + _fsIOCSetFlags = uintptr(0x40086602) + _fsAppendFL = int64(0x00000020) +) + +// trySetAppendOnly attempts to set the FS_APPEND_FL attribute (chattr +a) on +// the file at path. Returns true if the flag was already set or successfully +// applied. Returns false on unsupported filesystems or insufficient privilege. +func trySetAppendOnly(path string) bool { + f, err := os.Open(path) //#nosec G304 -- construction-time path + if err != nil { + return false + } + defer func() { _ = f.Close() }() + + var flags int64 + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), _fsIOCGetFlags, uintptr(unsafe.Pointer(&flags))); errno != 0 { //#nosec G103 + return false + } + if flags&_fsAppendFL != 0 { + return true + } + flags |= _fsAppendFL + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), _fsIOCSetFlags, uintptr(unsafe.Pointer(&flags))) //#nosec G103 + return errno == 0 +} diff --git a/protect_other.go b/protect_other.go new file mode 100644 index 0000000..bd6e431 --- /dev/null +++ b/protect_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package writ + +// trySetAppendOnly returns false on non-Linux platforms where FS_APPEND_FL +// is not available. Use chattr +a manually on Linux to harden the chain file. +func trySetAppendOnly(_ string) bool { + return false +} diff --git a/store.go b/store.go index f86dcf5..c6ed2ff 100644 --- a/store.go +++ b/store.go @@ -52,6 +52,16 @@ func newJSONLStore(path string) (AuditStore, error) { if err := f.Close(); err != nil { return nil, err } + // Verify or restore 0600 — protects against umask drift and manual changes. + fi, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("writ: stat chain file: %w", err) + } + if fi.Mode().Perm() != 0o600 { + if err := os.Chmod(path, 0o600); err != nil { + return nil, fmt.Errorf("writ: chain file has insecure permissions (%#o) and chmod failed: %w", fi.Mode().Perm(), err) + } + } return &jsonlStore{path: path}, nil } diff --git a/writ.go b/writ.go index 12e3506..ec5f968 100644 --- a/writ.go +++ b/writ.go @@ -48,6 +48,11 @@ type Config struct { // fails Merkle verification. A ChainSegmentBoundary entry is written // recording the recovery event. Default false: corrupt chain → ErrCorruptChain. AllowCorruptChainRecovery bool + + // AllowedCallers restricts which CallerID values may open a writ.Client + // that writes to this chain. Nil or empty allows any CallerID. + // writ.New returns an error if CallerID is not in the list when it is set. + AllowedCallers []string } // ErrCorruptChain is returned by New() when the existing chain fails Merkle @@ -77,6 +82,19 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) { return nil, fmt.Errorf("writ: Config.PolicyPath is required") } + if len(cfg.AllowedCallers) > 0 { + permitted := false + for _, id := range cfg.AllowedCallers { + if id == cfg.CallerID { + permitted = true + break + } + } + if !permitted { + return nil, fmt.Errorf("writ: caller %q not in AllowedCallers", cfg.CallerID) + } + } + var store AuditStore if cfg.Store != nil { store = cfg.Store @@ -202,6 +220,19 @@ func (c *Client) Audit(event AuditEvent) error { return c.chain.Append(entry) } +// ChainProtected attempts to set the FS_APPEND_FL attribute (equivalent to +// chattr +a) on the chain file, preventing in-place overwrites at the +// filesystem level. Returns true if the flag is set or was successfully +// applied. Returns false if no AuditPath is configured, the filesystem does +// not support the attribute, or the process lacks sufficient privilege. +// On non-Linux platforms this always returns false. +func (c *Client) ChainProtected() bool { + if c.cfg.AuditPath == "" { + return false + } + return trySetAppendOnly(c.cfg.AuditPath) +} + // Verify reads the chain at chainPath and verifies the Merkle hash links. // Returns nil if the chain is intact, or an error describing the first broken link. // Also available as the `writ verify` CLI command. diff --git a/writ_test.go b/writ_test.go index 0c13c76..91d1915 100644 --- a/writ_test.go +++ b/writ_test.go @@ -1,12 +1,33 @@ package writ_test import ( + "os" + "path/filepath" "testing" "time" "github.com/opskernel-io/writ" ) +// testConfig creates a minimal writ.Config backed by a temp directory. +func testConfig(t *testing.T) writ.Config { + t.Helper() + dir := t.TempDir() + policyPath := filepath.Join(dir, "policy") + if err := os.MkdirAll(policyPath, 0o700); err != nil { + t.Fatalf("mkdir policy: %v", err) + } + const policy = "package writ.gate\nimport rego.v1\ndefault allow := true\ndefault tier := 2\ndefault denial_reason := \"\"" + if err := os.WriteFile(filepath.Join(policyPath, "writ.rego"), []byte(policy), 0o600); err != nil { + t.Fatalf("write test policy: %v", err) + } + return writ.Config{ + PolicyPath: policyPath, + AuditPath: filepath.Join(dir, "audit.chain"), + CallerID: "test-agent", + } +} + func TestMemoryStoreAppendAndVerify(t *testing.T) { store := writ.NewMemoryStore() @@ -58,3 +79,45 @@ func TestDenialErrorMessage(t *testing.T) { t.Fatal("DenialError.Error() returned empty string") } } + +// PR 1: AllowedCallers and ChainProtected tests. + +func TestAllowedCallersPermitsKnownCaller(t *testing.T) { + cfg := testConfig(t) + cfg.AllowedCallers = []string{"test-agent", "other-agent"} + c, err := writ.New(cfg) + if err != nil { + t.Fatalf("want success for known caller, got: %v", err) + } + _ = c +} + +func TestAllowedCallersDeniesUnknownCaller(t *testing.T) { + cfg := testConfig(t) + cfg.AllowedCallers = []string{"allowed-agent"} + cfg.CallerID = "unknown-agent" + if _, err := writ.New(cfg); err == nil { + t.Fatal("want error for unknown caller, got nil") + } +} + +func TestAllowedCallersNilPermitsAnyCaller(t *testing.T) { + cfg := testConfig(t) + cfg.AllowedCallers = nil + cfg.CallerID = "any-agent" + c, err := writ.New(cfg) + if err != nil { + t.Fatalf("want success for nil allowlist, got: %v", err) + } + _ = c +} + +func TestChainProtectedReturnsBool(t *testing.T) { + cfg := testConfig(t) + c, err := writ.New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + // Return value is platform/filesystem-dependent; just verify the call succeeds. + _ = c.ChainProtected() +}