Skip to content

Commit 79a2fd9

Browse files
OpsKernclaude
andcommitted
feat(access-protection): AllowedCallers, ChainProtected, mode verify
- Config.AllowedCallers []string: when non-empty, writ.New rejects any CallerID not in the list (backwards-compat: nil/empty allows all) - Client.ChainProtected() bool: attempts to set FS_APPEND_FL (chattr +a) via ioctl on Linux; always false on other platforms - newJSONLStore: verifies chain file is 0600 on open; auto-chmod if drifted - protect_linux.go / protect_other.go: OS-gated ioctl implementation - policies/default/caller_allowlist.rego: example policy for Rego-level caller filtering (commented-out; operators activate as needed) - Tests: AllowedCallers permit/deny/nil cases, ChainProtected smoke Closes partial EU AI Act Article 12 requirement: tamper-evident (access protection). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b125cb0 commit 79a2fd9

6 files changed

Lines changed: 175 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# caller_allowlist.rego — optional policy for restricting chain-writing callers.
2+
#
3+
# Activate by editing the set below and removing (or overriding) the
4+
# `default allow := true` rule in writ.rego.
5+
#
6+
# writ passes input.caller_id on every gate evaluation. Only callers in the
7+
# set below will be permitted to execute LLM calls (and write to the chain).
8+
#
9+
# Example (uncomment to activate):
10+
#
11+
# package writ.gate
12+
#
13+
# import rego.v1
14+
#
15+
# _allowed_callers := {"my-agent-v1", "my-agent-v2"}
16+
#
17+
# deny if {
18+
# count(_allowed_callers) > 0
19+
# not _allowed_callers[input.caller_id]
20+
# }
21+
#
22+
# allow if {
23+
# not deny
24+
# }

protect_linux.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//go:build linux
2+
3+
package writ
4+
5+
import (
6+
"os"
7+
"syscall"
8+
"unsafe" //#nosec G103 -- ioctl for FS_APPEND_FL; unsafe.Pointer is the only available interface
9+
)
10+
11+
// FS ioctl constants for 64-bit Linux (_IOR/'f'/1/sizeof(long) and _IOW/'f'/2/sizeof(long)).
12+
const (
13+
_fsIOCGetFlags = uintptr(0x80086601)
14+
_fsIOCSetFlags = uintptr(0x40086602)
15+
_fsAppendFL = int64(0x00000020)
16+
)
17+
18+
// trySetAppendOnly attempts to set the FS_APPEND_FL attribute (chattr +a) on
19+
// the file at path. Returns true if the flag was already set or successfully
20+
// applied. Returns false on unsupported filesystems or insufficient privilege.
21+
func trySetAppendOnly(path string) bool {
22+
f, err := os.Open(path) //#nosec G304 -- construction-time path
23+
if err != nil {
24+
return false
25+
}
26+
defer func() { _ = f.Close() }()
27+
28+
var flags int64
29+
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), _fsIOCGetFlags, uintptr(unsafe.Pointer(&flags))); errno != 0 { //#nosec G103
30+
return false
31+
}
32+
if flags&_fsAppendFL != 0 {
33+
return true
34+
}
35+
flags |= _fsAppendFL
36+
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), _fsIOCSetFlags, uintptr(unsafe.Pointer(&flags))) //#nosec G103
37+
return errno == 0
38+
}

protect_other.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build !linux
2+
3+
package writ
4+
5+
// trySetAppendOnly returns false on non-Linux platforms where FS_APPEND_FL
6+
// is not available. Use chattr +a manually on Linux to harden the chain file.
7+
func trySetAppendOnly(_ string) bool {
8+
return false
9+
}

store.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ func newJSONLStore(path string) (AuditStore, error) {
5252
if err := f.Close(); err != nil {
5353
return nil, err
5454
}
55+
// Verify or restore 0600 — protects against umask drift and manual changes.
56+
fi, err := os.Stat(path)
57+
if err != nil {
58+
return nil, fmt.Errorf("writ: stat chain file: %w", err)
59+
}
60+
if fi.Mode().Perm() != 0o600 {
61+
if err := os.Chmod(path, 0o600); err != nil {
62+
return nil, fmt.Errorf("writ: chain file has insecure permissions (%#o) and chmod failed: %w", fi.Mode().Perm(), err)
63+
}
64+
}
5565
return &jsonlStore{path: path}, nil
5666
}
5767

writ.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ type Config struct {
4242
// EagerReload enables a goroutine-based policy watcher.
4343
// Default (false) uses lazy reload: policy is re-read when mtime changes.
4444
EagerReload bool
45+
46+
// AllowedCallers restricts which CallerID values may open a writ.Client
47+
// that writes to this chain. Nil or empty allows any CallerID.
48+
// writ.New returns an error if CallerID is not in the list when it is set.
49+
AllowedCallers []string
4550
}
4651

4752
// Client wraps an anthropic.Client with a pre-call codification gate and
@@ -67,6 +72,19 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) {
6772
return nil, fmt.Errorf("writ: Config.PolicyPath is required")
6873
}
6974

75+
if len(cfg.AllowedCallers) > 0 {
76+
permitted := false
77+
for _, id := range cfg.AllowedCallers {
78+
if id == cfg.CallerID {
79+
permitted = true
80+
break
81+
}
82+
}
83+
if !permitted {
84+
return nil, fmt.Errorf("writ: caller %q not in AllowedCallers", cfg.CallerID)
85+
}
86+
}
87+
7088
var store AuditStore
7189
if cfg.Store != nil {
7290
store = cfg.Store
@@ -188,6 +206,19 @@ func (c *Client) Audit(event AuditEvent) error {
188206
return c.chain.Append(entry)
189207
}
190208

209+
// ChainProtected attempts to set the FS_APPEND_FL attribute (equivalent to
210+
// chattr +a) on the chain file, preventing in-place overwrites at the
211+
// filesystem level. Returns true if the flag is set or was successfully
212+
// applied. Returns false if no AuditPath is configured, the filesystem does
213+
// not support the attribute, or the process lacks sufficient privilege.
214+
// On non-Linux platforms this always returns false.
215+
func (c *Client) ChainProtected() bool {
216+
if c.cfg.AuditPath == "" {
217+
return false
218+
}
219+
return trySetAppendOnly(c.cfg.AuditPath)
220+
}
221+
191222
// Verify reads the chain at chainPath and verifies the Merkle hash links.
192223
// Returns nil if the chain is intact, or an error describing the first broken link.
193224
// Also available as the `writ verify` CLI command.

writ_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
package writ_test
22

33
import (
4+
"os"
5+
"path/filepath"
46
"testing"
57
"time"
68

79
"github.com/opskernel-io/writ"
810
)
911

12+
// testConfig creates a minimal writ.Config backed by a temp directory.
13+
func testConfig(t *testing.T) writ.Config {
14+
t.Helper()
15+
dir := t.TempDir()
16+
policyPath := filepath.Join(dir, "policy")
17+
if err := os.MkdirAll(policyPath, 0o700); err != nil {
18+
t.Fatalf("mkdir policy: %v", err)
19+
}
20+
const policy = "package writ.gate\nimport rego.v1\ndefault allow := true\ndefault tier := 2\ndefault denial_reason := \"\""
21+
if err := os.WriteFile(filepath.Join(policyPath, "writ.rego"), []byte(policy), 0o600); err != nil {
22+
t.Fatalf("write test policy: %v", err)
23+
}
24+
return writ.Config{
25+
PolicyPath: policyPath,
26+
AuditPath: filepath.Join(dir, "audit.chain"),
27+
CallerID: "test-agent",
28+
}
29+
}
30+
1031
func TestMemoryStoreAppendAndVerify(t *testing.T) {
1132
store := writ.NewMemoryStore()
1233

@@ -58,3 +79,45 @@ func TestDenialErrorMessage(t *testing.T) {
5879
t.Fatal("DenialError.Error() returned empty string")
5980
}
6081
}
82+
83+
// PR 1: AllowedCallers and ChainProtected tests.
84+
85+
func TestAllowedCallersPermitsKnownCaller(t *testing.T) {
86+
cfg := testConfig(t)
87+
cfg.AllowedCallers = []string{"test-agent", "other-agent"}
88+
c, err := writ.New(cfg)
89+
if err != nil {
90+
t.Fatalf("want success for known caller, got: %v", err)
91+
}
92+
_ = c
93+
}
94+
95+
func TestAllowedCallersDeniesUnknownCaller(t *testing.T) {
96+
cfg := testConfig(t)
97+
cfg.AllowedCallers = []string{"allowed-agent"}
98+
cfg.CallerID = "unknown-agent"
99+
if _, err := writ.New(cfg); err == nil {
100+
t.Fatal("want error for unknown caller, got nil")
101+
}
102+
}
103+
104+
func TestAllowedCallersNilPermitsAnyCaller(t *testing.T) {
105+
cfg := testConfig(t)
106+
cfg.AllowedCallers = nil
107+
cfg.CallerID = "any-agent"
108+
c, err := writ.New(cfg)
109+
if err != nil {
110+
t.Fatalf("want success for nil allowlist, got: %v", err)
111+
}
112+
_ = c
113+
}
114+
115+
func TestChainProtectedReturnsBool(t *testing.T) {
116+
cfg := testConfig(t)
117+
c, err := writ.New(cfg)
118+
if err != nil {
119+
t.Fatalf("New: %v", err)
120+
}
121+
// Return value is platform/filesystem-dependent; just verify the call succeeds.
122+
_ = c.ChainProtected()
123+
}

0 commit comments

Comments
 (0)