Skip to content
Open
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
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ require (
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,19 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
95 changes: 82 additions & 13 deletions internal/adapters/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
// (memory only) or external embedder process.
package config

import "time"
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)

// Config — top-level. Read-only by rune-mcp (write path: /rune:configure CLI).
type Config struct {
Expand All @@ -29,30 +35,93 @@ type VaultConfig struct {
// FilePerms — per rune-mcp.md §Config:
// ~/.rune/ 0700
// ~/.rune/config.json 0600
// ~/.rune/logs/ 0700
// ~/.rune/keys/ 0700
// ~/.rune/keys/<id>/ 0700
// ~/.rune/keys/.../*.json 0600
// ~/.rune/capture_log.jsonl 0600
const (
DirPerm = 0700
FilePerm = 0600
)

// DormantParsedSince parses DormantSince to time.Time (zero if empty/invalid).
func DormantParsedSince(c *Config) time.Time {
if c.DormantSince == "" {
return time.Time{}
}

// DormantSince to time.Time
t, _ := time.Parse(time.RFC3339, c.DormantSince)
return t
}

// Load reads ~/.rune/config.json.
// TODO: implement with os.UserHomeDir + os.Open + json.Decode.
// TODO: EnsureDirectories (0700 force via os.Chmod, Python L358-365).
// TODO: env var override RUNE_STATE (Python has 12+; Go drops 11, keeps this one).
func (c *Config) IsActive() bool {
return c.State == "active"
}

func RuneDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("config: UserHomeDir: %w", err)
}
return filepath.Join(home, ".rune"), nil // ~/.rune
}

func DefaultConfigPath() (string, error) {
dir, err := RuneDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.json"), nil // ~/.rune/config.json
}

func Load() (*Config, error) {
// TODO
return nil, nil
configPath, err := DefaultConfigPath()
if err != nil {
return nil, err
}
return LoadFromPath(configPath)
}

func LoadFromPath(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}

if stateOverride := os.Getenv("RUNE_STATE"); stateOverride != "" {
cfg.State = stateOverride
}

return &cfg, nil
}

func EnsureDirectories() error {
dir, err := RuneDir()
if err != nil {
return err
}

// Create directory if not exists
if err := os.MkdirAll(dir, DirPerm); err != nil {
return fmt.Errorf("config: mkdir %s: %w", dir, err)
}

// Force permissions
if err := os.Chmod(dir, DirPerm); err != nil {
return fmt.Errorf("config: chmod %s: %w", dir, err)
}

// Ensure subdirectories
for _, sub := range []string{"keys", "logs"} {
subDir := filepath.Join(dir, sub)
if err := os.MkdirAll(subDir, DirPerm); err != nil {
return fmt.Errorf("config: mkdir %s: %w", subDir, err)
}
if err := os.Chmod(subDir, DirPerm); err != nil {
return fmt.Errorf("config: chmod %s: %w", subDir, err)
}
}

return nil
}
27 changes: 11 additions & 16 deletions internal/adapters/embedder/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"context"
"fmt"
"time"

"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// retry executes call() with the D7 backoff schedule [0, 500ms, 2s].
Expand All @@ -18,11 +21,6 @@ import (
// ResourceExhausted — daemon overloaded
//
// Non-retryable errors (e.g., InvalidArgument) return immediately.
//
// Uses Go generics (Go 1.18+). Single helper reused across Embed / EmbedBatch /
// Info / Health per the embedder.md §Client 구현 reference.
//
// TODO: requires google.golang.org/grpc/status + codes (external deps).
func retry[R any](ctx context.Context, call func(context.Context) (R, error)) (R, error) {
var zero R
var lastErr error
Expand Down Expand Up @@ -50,17 +48,14 @@ func retry[R any](ctx context.Context, call func(context.Context) (R, error)) (R
//
// Unavailable / DeadlineExceeded / ResourceExhausted → true
// other (including non-gRPC errors) → false
//
// TODO: implement with google.golang.org/grpc/{status,codes}.
//
// st, ok := status.FromError(err)
// if !ok { return false }
// switch st.Code() {
// case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted:
// return true
// }
// return false
func retryable(err error) bool {
_ = err
st, ok := status.FromError(err)
if !ok {
return false
}
switch st.Code() {
case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted:
return true
}
return false
}
91 changes: 83 additions & 8 deletions internal/adapters/envector/aes_ctr.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
package envector

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
)

// AES-256-CTR envelope for metadata.
// Spec: docs/v04/spec/components/rune-mcp.md §AES envelope.
// Python: mcp/adapter/envector_sdk.py:L227-234 _app_encrypt_metadata
Expand All @@ -15,17 +25,82 @@ package envector
// Recall path: service layer calls Vault.DecryptMetadata (Vault owns agent_dek
// for recall too — audit trail). This adapter does NOT call Open.

// Seal — Python envector_sdk.py:L227-234.
// TODO: crypto/aes + crypto/cipher.NewCTR + crypto/rand (16B IV) + base64 + JSON marshal.
type envelope struct {
A string `json:"a"` // agent_id
C string `json:"c"` // base64(IV || ciphertext)
}

// Encrypts plaintext with AES-256-CTR using random 16-byte IV
// Returns: {"a": "<agent_id>", "c": "<base64(IV||CT)>"}
func Seal(dek []byte, agentID string, plaintext []byte) (string, error) {
// TODO: bit-identical to _app_encrypt_metadata
return "", nil
if len(dek) != 32 {
return "", fmt.Errorf("envector: invalid DEK size %d (expected 32)", len(dek))
}

block, err := aes.NewCipher(dek)
if err != nil {
return "", fmt.Errorf("envector: aes.NewCipher: %w", err)
}

// 16-byte random IV
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", fmt.Errorf("envector: rand IV: %w", err)
}

// CTR encrypt
ct := make([]byte, len(plaintext))
stream := cipher.NewCTR(block, iv)
stream.XORKeyStream(ct, plaintext)

// iv || ct -> base64
combined := append(iv, ct...)
b64 := base64.StdEncoding.EncodeToString(combined)

env := envelope{A: agentID, C: b64}
data, err := json.Marshal(env)
if err != nil {
return "", fmt.Errorf("envector: json marshal envelope: %w", err)
}
return string(data), nil
}

// Open — reserved for potential local-decrypt path (currently Vault-delegated).
// Keep as interface for testing; production uses Vault.DecryptMetadata.
// TODO: mirror Seal for tests.
func Open(dek []byte, agentID string, envelope string) ([]byte, error) {
// TODO
return nil, nil
func Open(dek []byte, agentID string, envelopeStr string) ([]byte, error) {
if len(dek) != 32 {
return nil, fmt.Errorf("envector: invalid DEK size %d (expected 32)", len(dek))
}

var env envelope
if err := json.Unmarshal([]byte(envelopeStr), &env); err != nil {
return nil, fmt.Errorf("envector: invalid envelope JSON: %w", err)
}

if env.A != agentID {
return nil, fmt.Errorf("envector: agent_id mismatch: got %q, want %q", env.A, agentID)
}

combined, err := base64.StdEncoding.DecodeString(env.C)
if err != nil {
return nil, fmt.Errorf("envector: base64 decode: %w", err)
}

if len(combined) < aes.BlockSize {
return nil, fmt.Errorf("envector: ciphertext too short (len=%d)", len(combined))
}

iv := combined[:aes.BlockSize]
ct := combined[aes.BlockSize:]

block, err := aes.NewCipher(dek)
if err != nil {
return nil, fmt.Errorf("envector: aes.NewCipher: %w", err)
}

plaintext := make([]byte, len(ct))
stream := cipher.NewCTR(block, iv)
stream.XORKeyStream(plaintext, ct)

return plaintext, nil
}
Loading
Loading