Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions policies/default/caller_allowlist.rego
Original file line number Diff line number Diff line change
@@ -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
# }
38 changes: 38 additions & 0 deletions protect_linux.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions protect_other.go
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
31 changes: 31 additions & 0 deletions writ.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions writ_test.go
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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()
}
Loading