Compare commits

...

12 commits
v0.1.10 ... dev

Author SHA1 Message Date
Virgil
c9a7a6fb4b fix(trust): enforce scoped repository defaults
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Failing after 33s
2026-03-30 10:42:05 +00:00
86c68ad1c9 Merge pull request '[agent/codex:gpt-5.3-codex-spark] Read .core/reference/RFC-CORE-008-AGENT-EXPERIENCE.md (the A...' (#14) from main into dev
Some checks failed
Security Scan / security (push) Failing after 15s
Test / test (push) Successful in 10m52s
2026-03-29 15:26:33 +00:00
Virgil
e80ef94552 fix(crypt): align AX error handling and cleanup checks
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 9m7s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 15:25:12 +00:00
f37f5b3a14 Merge pull request 'Fix CodeRabbit findings' (#12) from agent/fix-coderabbit-findings--verify-each-aga into dev
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 8m44s
Reviewed-on: #12
2026-03-24 11:33:05 +00:00
12281f9e76 Merge pull request '[agent/claude] Update go.mod require lines from forge.lthn.ai to dappco.re ...' (#6) from agent/update-go-mod-require-lines-from-forge-l into main
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 10m27s
2026-03-22 01:44:22 +00:00
Snider
62482c7dc9 refactor: migrate imports to dappco.re/go/core/* paths
Some checks failed
Security Scan / security (pull_request) Failing after 9s
Test / test (pull_request) Failing after 8m29s
Update module path from forge.lthn.ai/core/go-crypt to
dappco.re/go/core/crypt. Migrate go-log, go-io, go-i18n imports to
their new dappco.re/go/core/* paths with updated versions (core v0.5.0,
log v0.1.0, io v0.2.0, i18n v0.2.0). Un-migrated modules (cli,
go-store, go-inference) remain at forge.lthn.ai paths.

Also fixes merge conflict marker and duplicate imports in
crypt/openpgp/service.go, and updates CLAUDE.md to reflect new paths.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 01:43:27 +00:00
Snider
69464fe503 refactor: migrate core import to dappco.re/go/core
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 7m16s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:56:26 +00:00
Snider
b85319ae6b chore: sync dependencies for v0.1.12
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:54:33 +00:00
Snider
f5b4c971a2 chore: sync dependencies for v0.1.11
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:54:33 +00:00
Snider
36bf16b06e fix(coderabbit): address review findings
Some checks failed
Security Scan / security (pull_request) Failing after 8s
Test / test (pull_request) Failing after 4m46s
- auth: prevent legacy .lthn fallback when .hash file exists but is
  unreadable or has unexpected format (security fix in verifyPassword
  and Login)
- chachapoly: wrap raw error returns in Decrypt with coreerr.E()
- trust: reject trailing data in LoadPolicies JSON decoder

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 13:32:21 +00:00
e691a9ce51 Merge pull request '[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate...' (#3) from agent/dx-audit-and-fix--1--review-claude-md into main
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Successful in 1m15s
2026-03-17 08:03:20 +00:00
Snider
703dd4588c refactor: standardise coreerr import alias and fix shortenPackageName
Some checks failed
Security Scan / security (pull_request) Failing after 7s
Test / test (pull_request) Successful in 11m55s
- CLAUDE.md: update error convention from core.E() to coreerr.E() to
  match actual codebase usage
- Standardise go-log import alias from `core` to `coreerr` across 6
  files (crypt/symmetric.go, crypt/kdf.go, crypt/crypt.go, crypt/hash.go,
  crypt/checksum.go, crypt/openpgp/service.go) for consistency with the
  11 files already using `coreerr`
- Fix shortenPackageName to handle all forge.lthn.ai/core/* module
  prefixes instead of only cli/ and gui/, fixing TestShortenPackageName

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 07:22:34 +00:00
34 changed files with 291 additions and 218 deletions

View file

@ -2,7 +2,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You are a dedicated domain expert for `forge.lthn.ai/core/go-crypt`. Virgil (in
You are a dedicated domain expert for `dappco.re/go/core/crypt`. Virgil (in
core/go) orchestrates your work. Pick up tasks in phase order, mark `[x]` when
done, commit and push.
@ -39,16 +39,16 @@ go test -bench=. -benchmem ./crypt/... # Benchmarks
## Local Dependencies
All `forge.lthn.ai/core/*` modules are resolved through the Go workspace
All `dappco.re/go/core/*` and remaining `forge.lthn.ai/core/*` modules are resolved through the Go workspace
(`~/Code/go.work`). Do not add replace directives to `go.mod` — use the
workspace file instead.
| Module | Local Path | Purpose |
|--------|-----------|---------|
| `forge.lthn.ai/core/go` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
| `dappco.re/go/core` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
| `dappco.re/go/core/log` | `../go-log` | `coreerr.E()` contextual error wrapping |
| `dappco.re/go/core/io` | `../go-io` | `io.Medium` storage abstraction |
| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store (session persistence) |
| `forge.lthn.ai/core/go-io` | `../go-io` | `io.Medium` storage abstraction |
| `forge.lthn.ai/core/go-log` | `../go-log` | `core.E()` contextual error wrapping |
| `forge.lthn.ai/core/cli` | `../cli` | CLI framework for `cmd/crypt` commands |
No C toolchain or CGo required — all crypto uses pure Go implementations.
@ -58,9 +58,9 @@ No C toolchain or CGo required — all crypto uses pure Go implementations.
- **UK English**: colour, organisation, centre, artefact, licence, serialise
- **Tests**: testify assert/require, `_Good`/`_Bad`/`_Ugly` naming convention
- **Concurrency tests**: 10 goroutines via WaitGroup; must pass `-race`
- **Imports**: stdlib → forge.lthn.ai → third-party, separated by blank lines
- **Errors**: use `core.E("package.Function", "lowercase message", err)` (imported
from `forge.lthn.ai/core/go-log`); never include secrets in error strings
- **Imports**: stdlib → dappco.re/forge.lthn.ai → third-party, separated by blank lines
- **Errors**: use `coreerr.E("package.Function", "lowercase message", err)` (imported
as `coreerr "dappco.re/go/core/log"`); never include secrets in error strings
- **Randomness**: `crypto/rand` only; never `math/rand`
- **Conventional commits**: `feat(auth):`, `fix(crypt):`, `refactor(trust):`
Scopes match package names: `auth`, `crypt`, `trust`, `pgp`, `lthn`, `rsa`,
@ -70,6 +70,6 @@ No C toolchain or CGo required — all crypto uses pure Go implementations.
## Forge
- **Repo**: `forge.lthn.ai/core/go-crypt`
- **Repo**: `dappco.re/go/core/crypt`
- **Push via SSH**: `git push forge main`
(remote: `ssh://git@forge.lthn.ai:2223/core/go-crypt.git`)

View file

@ -35,12 +35,11 @@ import (
"sync"
"time"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-crypt/crypt"
"forge.lthn.ai/core/go-crypt/crypt/lthn"
"forge.lthn.ai/core/go-crypt/crypt/pgp"
"forge.lthn.ai/core/go-io"
"dappco.re/go/core/crypt/crypt"
"dappco.re/go/core/crypt/crypt/lthn"
"dappco.re/go/core/crypt/crypt/pgp"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
)
// Default durations for challenge and session lifetimes.
@ -324,7 +323,9 @@ func (a *Authenticator) ValidateSession(token string) (*Session, error) {
}
if time.Now().After(session.ExpiresAt) {
_ = a.store.Delete(token)
if err := a.store.Delete(token); err != nil {
return nil, coreerr.E(op, "session expired", err)
}
return nil, coreerr.E(op, "session expired", nil)
}
@ -341,7 +342,9 @@ func (a *Authenticator) RefreshSession(token string) (*Session, error) {
}
if time.Now().After(session.ExpiresAt) {
_ = a.store.Delete(token)
if err := a.store.Delete(token); err != nil {
return nil, coreerr.E(op, "session expired", err)
}
return nil, coreerr.E(op, "session expired", nil)
}
@ -390,7 +393,9 @@ func (a *Authenticator) DeleteUser(userID string) error {
}
// Revoke any active sessions for this user
_ = a.store.DeleteByUser(userID)
if err := a.store.DeleteByUser(userID); err != nil {
return coreerr.E(op, "failed to delete user sessions", err)
}
return nil
}
@ -420,19 +425,21 @@ func (a *Authenticator) Login(userID, password string) (*Session, error) {
return nil, coreerr.E(op, "failed to read password hash", err)
}
if strings.HasPrefix(storedHash, "$argon2id$") {
valid, err := crypt.VerifyPassword(password, storedHash)
if err != nil {
return nil, coreerr.E(op, "failed to verify password", err)
}
if !valid {
return nil, coreerr.E(op, "invalid password", nil)
}
return a.createSession(userID)
if !strings.HasPrefix(storedHash, "$argon2id$") {
return nil, coreerr.E(op, "corrupted password hash", nil)
}
valid, err := crypt.VerifyPassword(password, storedHash)
if err != nil {
return nil, coreerr.E(op, "failed to verify password", err)
}
if !valid {
return nil, coreerr.E(op, "invalid password", nil)
}
return a.createSession(userID)
}
// Fall back to legacy LTHN hash (.lthn file)
// Fall back to legacy LTHN hash (.lthn file) — only when no .hash file exists
storedHash, err := a.medium.Read(userPath(userID, ".lthn"))
if err != nil {
return nil, coreerr.E(op, "user not found", err)
@ -566,7 +573,9 @@ func (a *Authenticator) RevokeKey(userID, password, reason string) error {
}
// Invalidate all sessions
_ = a.store.DeleteByUser(userID)
if err := a.store.DeleteByUser(userID); err != nil {
return coreerr.E(op, "failed to delete user sessions", err)
}
return nil
}
@ -646,19 +655,25 @@ func (a *Authenticator) verifyPassword(userID, password string) error {
// Try Argon2id hash first (.hash file)
if a.medium.IsFile(userPath(userID, ".hash")) {
storedHash, err := a.medium.Read(userPath(userID, ".hash"))
if err == nil && strings.HasPrefix(storedHash, "$argon2id$") {
valid, verr := crypt.VerifyPassword(password, storedHash)
if verr != nil {
return coreerr.E(op, "failed to verify password", nil)
}
if !valid {
return coreerr.E(op, "invalid password", nil)
}
return nil
if err != nil {
return coreerr.E(op, "failed to read password hash", err)
}
if !strings.HasPrefix(storedHash, "$argon2id$") {
return coreerr.E(op, "corrupted password hash", nil)
}
valid, verr := crypt.VerifyPassword(password, storedHash)
if verr != nil {
return coreerr.E(op, "failed to verify password", verr)
}
if !valid {
return coreerr.E(op, "invalid password", nil)
}
return nil
}
// Fall back to legacy LTHN hash (.lthn file)
// Fall back to legacy LTHN hash (.lthn file) — only when no .hash file exists
storedHash, err := a.medium.Read(userPath(userID, ".lthn"))
if err != nil {
return coreerr.E(op, "user not found", nil)

View file

@ -11,9 +11,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"forge.lthn.ai/core/go-crypt/crypt/lthn"
"forge.lthn.ai/core/go-crypt/crypt/pgp"
"forge.lthn.ai/core/go-io"
"dappco.re/go/core/crypt/crypt/lthn"
"dappco.re/go/core/crypt/crypt/pgp"
"dappco.re/go/core/io"
)
// helper creates a fresh Authenticator backed by MockMedium.

View file

@ -5,7 +5,7 @@ import (
"sync"
"time"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// ErrSessionNotFound is returned when a session token is not found.

View file

@ -12,8 +12,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"forge.lthn.ai/core/go-crypt/crypt/lthn"
"forge.lthn.ai/core/go-io"
"dappco.re/go/core/crypt/crypt/lthn"
"dappco.re/go/core/io"
)
// --- MemorySessionStore ---

View file

@ -4,8 +4,8 @@ import (
"fmt"
"path/filepath"
"dappco.re/go/core/crypt/crypt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-crypt/crypt"
)
// Checksum command flags

View file

@ -4,9 +4,9 @@ import (
"fmt"
"strings"
"dappco.re/go/core/crypt/crypt"
coreio "dappco.re/go/core/io"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-crypt/crypt"
coreio "forge.lthn.ai/core/go-io"
)
// Encrypt command flags

View file

@ -3,8 +3,9 @@ package crypt
import (
"fmt"
"dappco.re/go/core/crypt/crypt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-crypt/crypt"
"golang.org/x/crypto/bcrypt"
)

View file

@ -4,8 +4,8 @@
package testcmd
import (
"dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
// Style aliases from shared

View file

@ -10,7 +10,7 @@ import (
"strconv"
"strings"
"forge.lthn.ai/core/go-i18n"
"dappco.re/go/core/i18n"
)
type packageCoverage struct {
@ -33,8 +33,8 @@ func parseTestOutput(output string) testResults {
results := testResults{}
// Regex patterns - handle both timed and cached test results
// Example: ok forge.lthn.ai/core/go-crypt/crypt 0.015s coverage: 91.2% of statements
// Example: ok forge.lthn.ai/core/go-crypt/crypt (cached) coverage: 91.2% of statements
// Example: ok dappco.re/go/core/crypt/crypt 0.015s coverage: 91.2% of statements
// Example: ok dappco.re/go/core/crypt/crypt (cached) coverage: 91.2% of statements
okPattern := regexp.MustCompile(`^ok\s+(\S+)\s+(?:[\d.]+s|\(cached\))(?:\s+coverage:\s+([\d.]+)%)?`)
failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`)
skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`)
@ -171,15 +171,15 @@ func formatCoverage(cov float64) string {
}
func shortenPackageName(name string) string {
// Remove common prefixes
prefixes := []string{
"forge.lthn.ai/core/cli/",
"forge.lthn.ai/core/gui/",
}
for _, prefix := range prefixes {
if strings.HasPrefix(name, prefix) {
return strings.TrimPrefix(name, prefix)
const modulePrefix = "dappco.re/go/"
if strings.HasPrefix(name, modulePrefix) {
remainder := strings.TrimPrefix(name, modulePrefix)
// If there's a sub-path (e.g. "go/pkg/foo"), strip the module name
if idx := strings.Index(remainder, "/"); idx >= 0 {
return remainder[idx+1:]
}
// Module root (e.g. "cli-php") — return as-is
return remainder
}
return filepath.Base(name)
}

View file

@ -9,8 +9,8 @@ import (
"runtime"
"strings"
"forge.lthn.ai/core/go-i18n"
coreerr "forge.lthn.ai/core/go-log"
"dappco.re/go/core/i18n"
coreerr "dappco.re/go/core/log"
)
func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error {
@ -49,7 +49,11 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
// Create command
cmd := exec.Command("go", args...)
cmd.Dir, _ = os.Getwd()
cwd, err := os.Getwd()
if err != nil {
return coreerr.E("cmd.test", "failed to determine working directory", err)
}
cmd.Dir = cwd
// Set environment to suppress macOS linker warnings
cmd.Env = append(os.Environ(), getMacOSDeploymentTarget())
@ -76,7 +80,7 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
cmd.Stderr = &stderr
}
err := cmd.Run()
err = cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {

View file

@ -7,7 +7,7 @@ import (
)
func TestShortenPackageName(t *testing.T) {
assert.Equal(t, "pkg/foo", shortenPackageName("forge.lthn.ai/core/go/pkg/foo"))
assert.Equal(t, "pkg/foo", shortenPackageName("dappco.re/go/core/pkg/foo"))
assert.Equal(t, "cli-php", shortenPackageName("forge.lthn.ai/core/cli-php"))
assert.Equal(t, "bar", shortenPackageName("github.com/other/bar"))
}
@ -19,16 +19,16 @@ func TestFormatCoverageTest(t *testing.T) {
}
func TestParseTestOutput(t *testing.T) {
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements
FAIL forge.lthn.ai/core/go/pkg/bar
? forge.lthn.ai/core/go/pkg/baz [no test files]
output := `ok dappco.re/go/core/pkg/foo 0.100s coverage: 50.0% of statements
FAIL dappco.re/go/core/pkg/bar
? dappco.re/go/core/pkg/baz [no test files]
`
results := parseTestOutput(output)
assert.Equal(t, 1, results.passed)
assert.Equal(t, 1, results.failed)
assert.Equal(t, 1, results.skipped)
assert.Equal(t, 1, len(results.failedPkgs))
assert.Equal(t, "forge.lthn.ai/core/go/pkg/bar", results.failedPkgs[0])
assert.Equal(t, "dappco.re/go/core/pkg/bar", results.failedPkgs[0])
assert.Equal(t, 1, len(results.packages))
assert.Equal(t, 50.0, results.packages[0].coverage)
}
@ -37,8 +37,8 @@ func TestPrintCoverageSummarySafe(t *testing.T) {
// This tests the bug fix for long package names causing negative Repeat count
results := testResults{
packages: []packageCoverage{
{name: "forge.lthn.ai/core/go/pkg/short", coverage: 100, hasCov: true},
{name: "forge.lthn.ai/core/go/pkg/a-very-very-very-very-very-long-package-name-that-might-cause-issues", coverage: 80, hasCov: true},
{name: "dappco.re/go/core/pkg/short", coverage: 100, hasCov: true},
{name: "dappco.re/go/core/pkg/a-very-very-very-very-very-long-package-name-that-might-cause-issues", coverage: 80, hasCov: true},
},
passed: 2,
totalCov: 180,

View file

@ -5,7 +5,7 @@ import (
"fmt"
"io"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/chacha20poly1305"
)
@ -27,21 +27,23 @@ func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
// Decrypt decrypts data using ChaCha20-Poly1305.
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
const op = "chachapoly.Decrypt"
aead, err := chacha20poly1305.NewX(key)
if err != nil {
return nil, err
return nil, coreerr.E(op, "failed to create cipher", err)
}
minLen := aead.NonceSize() + aead.Overhead()
if len(ciphertext) < minLen {
return nil, coreerr.E("chachapoly.Decrypt", fmt.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil)
return nil, coreerr.E(op, fmt.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil)
}
nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():]
decrypted, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
return nil, coreerr.E(op, "decryption failed", err)
}
if len(decrypted) == 0 {

View file

@ -2,9 +2,10 @@ package chachapoly
import (
"crypto/rand"
"errors"
"testing"
coreerr "dappco.re/go/core/log"
"github.com/stretchr/testify/assert"
)
@ -12,7 +13,7 @@ import (
type mockReader struct{}
func (r *mockReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
return 0, coreerr.E("chachapoly.mockReader.Read", "read error", nil)
}
func TestEncryptDecrypt(t *testing.T) {

View file

@ -7,20 +7,20 @@ import (
"io"
"os"
core "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// SHA256File computes the SHA-256 checksum of a file and returns it as a hex string.
func SHA256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", core.E("crypt.SHA256File", "failed to open file", err)
return "", coreerr.E("crypt.SHA256File", "failed to open file", err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", core.E("crypt.SHA256File", "failed to read file", err)
return "", coreerr.E("crypt.SHA256File", "failed to read file", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
@ -30,13 +30,13 @@ func SHA256File(path string) (string, error) {
func SHA512File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", core.E("crypt.SHA512File", "failed to open file", err)
return "", coreerr.E("crypt.SHA512File", "failed to open file", err)
}
defer func() { _ = f.Close() }()
h := sha512.New()
if _, err := io.Copy(h, f); err != nil {
return "", core.E("crypt.SHA512File", "failed to read file", err)
return "", coreerr.E("crypt.SHA512File", "failed to read file", err)
}
return hex.EncodeToString(h.Sum(nil)), nil

View file

@ -1,7 +1,7 @@
package crypt
import (
core "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// Encrypt encrypts data with a passphrase using ChaCha20-Poly1305.
@ -10,14 +10,14 @@ import (
func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
salt, err := generateSalt(argon2SaltLen)
if err != nil {
return nil, core.E("crypt.Encrypt", "failed to generate salt", err)
return nil, coreerr.E("crypt.Encrypt", "failed to generate salt", err)
}
key := DeriveKey(passphrase, salt, argon2KeyLen)
encrypted, err := ChaCha20Encrypt(plaintext, key)
if err != nil {
return nil, core.E("crypt.Encrypt", "failed to encrypt", err)
return nil, coreerr.E("crypt.Encrypt", "failed to encrypt", err)
}
// Prepend salt to the encrypted data (which already has nonce prepended)
@ -31,7 +31,7 @@ func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
// Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
if len(ciphertext) < argon2SaltLen {
return nil, core.E("crypt.Decrypt", "ciphertext too short", nil)
return nil, coreerr.E("crypt.Decrypt", "ciphertext too short", nil)
}
salt := ciphertext[:argon2SaltLen]
@ -41,7 +41,7 @@ func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
plaintext, err := ChaCha20Decrypt(encrypted, key)
if err != nil {
return nil, core.E("crypt.Decrypt", "failed to decrypt", err)
return nil, coreerr.E("crypt.Decrypt", "failed to decrypt", err)
}
return plaintext, nil
@ -53,14 +53,14 @@ func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
func EncryptAES(plaintext, passphrase []byte) ([]byte, error) {
salt, err := generateSalt(argon2SaltLen)
if err != nil {
return nil, core.E("crypt.EncryptAES", "failed to generate salt", err)
return nil, coreerr.E("crypt.EncryptAES", "failed to generate salt", err)
}
key := DeriveKey(passphrase, salt, argon2KeyLen)
encrypted, err := AESGCMEncrypt(plaintext, key)
if err != nil {
return nil, core.E("crypt.EncryptAES", "failed to encrypt", err)
return nil, coreerr.E("crypt.EncryptAES", "failed to encrypt", err)
}
result := make([]byte, 0, len(salt)+len(encrypted))
@ -73,7 +73,7 @@ func EncryptAES(plaintext, passphrase []byte) ([]byte, error) {
// Expects format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
func DecryptAES(ciphertext, passphrase []byte) ([]byte, error) {
if len(ciphertext) < argon2SaltLen {
return nil, core.E("crypt.DecryptAES", "ciphertext too short", nil)
return nil, coreerr.E("crypt.DecryptAES", "ciphertext too short", nil)
}
salt := ciphertext[:argon2SaltLen]
@ -83,7 +83,7 @@ func DecryptAES(ciphertext, passphrase []byte) ([]byte, error) {
plaintext, err := AESGCMDecrypt(encrypted, key)
if err != nil {
return nil, core.E("crypt.DecryptAES", "failed to decrypt", err)
return nil, coreerr.E("crypt.DecryptAES", "failed to decrypt", err)
}
return plaintext, nil

View file

@ -6,7 +6,8 @@ import (
"fmt"
"strings"
core "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
@ -16,7 +17,7 @@ import (
func HashPassword(password string) (string, error) {
salt, err := generateSalt(argon2SaltLen)
if err != nil {
return "", core.E("crypt.HashPassword", "failed to generate salt", err)
return "", coreerr.E("crypt.HashPassword", "failed to generate salt", err)
}
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Parallelism, argon2KeyLen)
@ -36,29 +37,29 @@ func HashPassword(password string) (string, error) {
func VerifyPassword(password, hash string) (bool, error) {
parts := strings.Split(hash, "$")
if len(parts) != 6 {
return false, core.E("crypt.VerifyPassword", "invalid hash format", nil)
return false, coreerr.E("crypt.VerifyPassword", "invalid hash format", nil)
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, core.E("crypt.VerifyPassword", "failed to parse version", err)
return false, coreerr.E("crypt.VerifyPassword", "failed to parse version", err)
}
var memory uint32
var time uint32
var parallelism uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &parallelism); err != nil {
return false, core.E("crypt.VerifyPassword", "failed to parse parameters", err)
return false, coreerr.E("crypt.VerifyPassword", "failed to parse parameters", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, core.E("crypt.VerifyPassword", "failed to decode salt", err)
return false, coreerr.E("crypt.VerifyPassword", "failed to decode salt", err)
}
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, core.E("crypt.VerifyPassword", "failed to decode hash", err)
return false, coreerr.E("crypt.VerifyPassword", "failed to decode hash", err)
}
computedHash := argon2.IDKey([]byte(password), salt, time, memory, parallelism, uint32(len(expectedHash)))
@ -71,7 +72,7 @@ func VerifyPassword(password, hash string) (bool, error) {
func HashBcrypt(password string, cost int) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
return "", core.E("crypt.HashBcrypt", "failed to hash password", err)
return "", coreerr.E("crypt.HashBcrypt", "failed to hash password", err)
}
return string(hash), nil
}
@ -83,7 +84,7 @@ func VerifyBcrypt(password, hash string) (bool, error) {
return false, nil
}
if err != nil {
return false, core.E("crypt.VerifyBcrypt", "failed to verify password", err)
return false, coreerr.E("crypt.VerifyBcrypt", "failed to verify password", err)
}
return true, nil
}

View file

@ -7,7 +7,8 @@ import (
"crypto/sha256"
"io"
core "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/scrypt"
@ -33,7 +34,7 @@ func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte {
func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error) {
key, err := scrypt.Key(passphrase, salt, 32768, 8, 1, keyLen)
if err != nil {
return nil, core.E("crypt.DeriveKeyScrypt", "failed to derive key", err)
return nil, coreerr.E("crypt.DeriveKeyScrypt", "failed to derive key", err)
}
return key, nil
}
@ -45,7 +46,7 @@ func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error) {
reader := hkdf.New(sha256.New, secret, salt, info)
key := make([]byte, keyLen)
if _, err := io.ReadFull(reader, key); err != nil {
return nil, core.E("crypt.HKDF", "failed to derive key", err)
return nil, coreerr.E("crypt.HKDF", "failed to derive key", err)
}
return key, nil
}
@ -54,7 +55,7 @@ func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error) {
func generateSalt(length int) ([]byte, error) {
salt := make([]byte, length)
if _, err := rand.Read(salt); err != nil {
return nil, core.E("crypt.generateSalt", "failed to generate random salt", err)
return nil, coreerr.E("crypt.generateSalt", "failed to generate random salt", err)
}
return salt, nil
}

View file

@ -6,15 +6,15 @@ import (
goio "io"
"strings"
framework "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
core "forge.lthn.ai/core/go-log"
framework "forge.lthn.ai/core/go/pkg/core"
)
// Service implements the framework.Crypt interface using OpenPGP.
// Service provides OpenPGP cryptographic operations.
type Service struct {
core *framework.Core
}
@ -36,19 +36,19 @@ func (s *Service) CreateKeyPair(name, passphrase string) (string, error) {
entity, err := openpgp.NewEntity(name, "Workspace Key", "", config)
if err != nil {
return "", core.E("openpgp.CreateKeyPair", "failed to create entity", err)
return "", coreerr.E("openpgp.CreateKeyPair", "failed to create entity", err)
}
// Encrypt private key if passphrase is provided
if passphrase != "" {
err = entity.PrivateKey.Encrypt([]byte(passphrase))
if err != nil {
return "", core.E("openpgp.CreateKeyPair", "failed to encrypt private key", err)
return "", coreerr.E("openpgp.CreateKeyPair", "failed to encrypt private key", err)
}
for _, subkey := range entity.Subkeys {
err = subkey.PrivateKey.Encrypt([]byte(passphrase))
if err != nil {
return "", core.E("openpgp.CreateKeyPair", "failed to encrypt subkey", err)
return "", coreerr.E("openpgp.CreateKeyPair", "failed to encrypt subkey", err)
}
}
}
@ -56,22 +56,22 @@ func (s *Service) CreateKeyPair(name, passphrase string) (string, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil {
return "", core.E("openpgp.CreateKeyPair", "failed to create armor encoder", err)
return "", coreerr.E("openpgp.CreateKeyPair", "failed to create armor encoder", err)
}
// Manual serialization to avoid panic from re-signing encrypted keys
err = s.serializeEntity(w, entity)
// Manual serialisation to avoid panic from re-signing encrypted keys
err = serializeEntity(w, entity)
if err != nil {
w.Close()
return "", core.E("openpgp.CreateKeyPair", "failed to serialize private key", err)
return "", coreerr.E("openpgp.CreateKeyPair", "failed to serialise private key", err)
}
w.Close()
return buf.String(), nil
}
// serializeEntity manually serializes an OpenPGP entity to avoid re-signing.
func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error {
// serializeEntity manually serialises an OpenPGP entity to avoid re-signing.
func serializeEntity(w goio.Writer, e *openpgp.Entity) error {
err := e.PrivateKey.Serialize(w)
if err != nil {
return err
@ -104,13 +104,13 @@ func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error {
func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) {
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath))
if err != nil {
return "", core.E("openpgp.EncryptPGP", "failed to read recipient key", err)
return "", coreerr.E("openpgp.EncryptPGP", "failed to read recipient key", err)
}
var armoredBuf bytes.Buffer
armoredWriter, err := armor.Encode(&armoredBuf, "PGP MESSAGE", nil)
if err != nil {
return "", core.E("openpgp.EncryptPGP", "failed to create armor encoder", err)
return "", coreerr.E("openpgp.EncryptPGP", "failed to create armor encoder", err)
}
// MultiWriter to write to both the provided writer and our armored buffer
@ -119,14 +119,14 @@ func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opt
w, err := openpgp.Encrypt(mw, entityList, nil, nil, nil)
if err != nil {
armoredWriter.Close()
return "", core.E("openpgp.EncryptPGP", "failed to start encryption", err)
return "", coreerr.E("openpgp.EncryptPGP", "failed to start encryption", err)
}
_, err = goio.WriteString(w, data)
if err != nil {
w.Close()
armoredWriter.Close()
return "", core.E("openpgp.EncryptPGP", "failed to write data", err)
return "", coreerr.E("openpgp.EncryptPGP", "failed to write data", err)
}
w.Close()
@ -139,35 +139,37 @@ func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opt
func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) {
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey))
if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to read private key", err)
return "", coreerr.E("openpgp.DecryptPGP", "failed to read private key", err)
}
entity := entityList[0]
if entity.PrivateKey.Encrypted {
err = entity.PrivateKey.Decrypt([]byte(passphrase))
if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to decrypt private key", err)
return "", coreerr.E("openpgp.DecryptPGP", "failed to decrypt private key", err)
}
for _, subkey := range entity.Subkeys {
_ = subkey.PrivateKey.Decrypt([]byte(passphrase))
if err := subkey.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return "", coreerr.E("openpgp.DecryptPGP", "failed to decrypt subkey", err)
}
}
}
// Decrypt armored message
block, err := armor.Decode(strings.NewReader(message))
if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to decode armored message", err)
return "", coreerr.E("openpgp.DecryptPGP", "failed to decode armored message", err)
}
md, err := openpgp.ReadMessage(block.Body, entityList, nil, nil)
if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to read message", err)
return "", coreerr.E("openpgp.DecryptPGP", "failed to read message", err)
}
var buf bytes.Buffer
_, err = goio.Copy(&buf, md.UnverifiedBody)
if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to read decrypted body", err)
return "", coreerr.E("openpgp.DecryptPGP", "failed to read decrypted body", err)
}
return buf.String(), nil
@ -188,6 +190,3 @@ func (s *Service) HandleIPCEvents(c *framework.Core, msg framework.Message) erro
}
return nil
}
// Ensure Service implements framework.Crypt.
var _ framework.Crypt = (*Service)(nil)

View file

@ -4,40 +4,40 @@ import (
"bytes"
"testing"
framework "forge.lthn.ai/core/go/pkg/core"
framework "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateKeyPair(t *testing.T) {
c, _ := framework.New()
c := framework.New()
s := &Service{core: c}
privKey, err := s.CreateKeyPair("test user", "password123")
assert.NoError(t, err)
assert.NotEmpty(t, privKey)
require.NoError(t, err)
require.NotEmpty(t, privKey)
assert.Contains(t, privKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
}
func TestEncryptDecrypt(t *testing.T) {
c, _ := framework.New()
c := framework.New()
s := &Service{core: c}
passphrase := "secret"
privKey, err := s.CreateKeyPair("test user", passphrase)
assert.NoError(t, err)
require.NoError(t, err)
// In this simple test, the public key is also in the armored private key string
// (openpgp.ReadArmoredKeyRing reads both)
// ReadArmoredKeyRing extracts public keys from armored private key blocks
publicKey := privKey
data := "hello openpgp"
var buf bytes.Buffer
armored, err := s.EncryptPGP(&buf, publicKey, data)
assert.NoError(t, err)
require.NoError(t, err)
assert.NotEmpty(t, armored)
assert.NotEmpty(t, buf.String())
decrypted, err := s.DecryptPGP(privKey, armored, passphrase)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, data, decrypted)
}

View file

@ -8,7 +8,7 @@ import (
"bytes"
"io"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
@ -34,7 +34,9 @@ func CreateKeyPair(name, email, password string) (*KeyPair, error) {
// Sign all the identities
for _, id := range entity.Identities {
_ = id.SelfSignature.SignUserId(id.UserId.Id, entity.PrimaryKey, entity.PrivateKey, nil)
if err := id.SelfSignature.SignUserId(id.UserId.Id, entity.PrimaryKey, entity.PrivateKey, nil); err != nil {
return nil, coreerr.E(op, "failed to sign identity", err)
}
}
// Encrypt private key with password if provided
@ -166,7 +168,9 @@ func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
}
for _, subkey := range entity.Subkeys {
if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted {
_ = subkey.PrivateKey.Decrypt([]byte(password))
if err := subkey.PrivateKey.Decrypt([]byte(password)); err != nil {
return nil, coreerr.E(op, "failed to decrypt subkey", err)
}
}
}
}

View file

@ -8,7 +8,7 @@ import (
"encoding/pem"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// Service provides RSA functionality.

View file

@ -6,9 +6,10 @@ import (
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"testing"
coreerr "dappco.re/go/core/log"
"github.com/stretchr/testify/assert"
)
@ -16,7 +17,7 @@ import (
type mockReader struct{}
func (r *mockReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
return 0, coreerr.E("rsa.mockReader.Read", "read error", nil)
}
func TestRSA_Good(t *testing.T) {

View file

@ -5,7 +5,8 @@ import (
"crypto/cipher"
"crypto/rand"
core "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/chacha20poly1305"
)
@ -15,12 +16,12 @@ import (
func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
return nil, core.E("crypt.ChaCha20Encrypt", "failed to create cipher", err)
return nil, coreerr.E("crypt.ChaCha20Encrypt", "failed to create cipher", err)
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, core.E("crypt.ChaCha20Encrypt", "failed to generate nonce", err)
return nil, coreerr.E("crypt.ChaCha20Encrypt", "failed to generate nonce", err)
}
ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
@ -32,18 +33,18 @@ func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) {
func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
return nil, core.E("crypt.ChaCha20Decrypt", "failed to create cipher", err)
return nil, coreerr.E("crypt.ChaCha20Decrypt", "failed to create cipher", err)
}
nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize {
return nil, core.E("crypt.ChaCha20Decrypt", "ciphertext too short", nil)
return nil, coreerr.E("crypt.ChaCha20Decrypt", "ciphertext too short", nil)
}
nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := aead.Open(nil, nonce, encrypted, nil)
if err != nil {
return nil, core.E("crypt.ChaCha20Decrypt", "failed to decrypt", err)
return nil, coreerr.E("crypt.ChaCha20Decrypt", "failed to decrypt", err)
}
return plaintext, nil
@ -55,17 +56,17 @@ func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) {
func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, core.E("crypt.AESGCMEncrypt", "failed to create cipher", err)
return nil, coreerr.E("crypt.AESGCMEncrypt", "failed to create cipher", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, core.E("crypt.AESGCMEncrypt", "failed to create GCM", err)
return nil, coreerr.E("crypt.AESGCMEncrypt", "failed to create GCM", err)
}
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, core.E("crypt.AESGCMEncrypt", "failed to generate nonce", err)
return nil, coreerr.E("crypt.AESGCMEncrypt", "failed to generate nonce", err)
}
ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
@ -77,23 +78,23 @@ func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) {
func AESGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, core.E("crypt.AESGCMDecrypt", "failed to create cipher", err)
return nil, coreerr.E("crypt.AESGCMDecrypt", "failed to create cipher", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, core.E("crypt.AESGCMDecrypt", "failed to create GCM", err)
return nil, coreerr.E("crypt.AESGCMDecrypt", "failed to create GCM", err)
}
nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize {
return nil, core.E("crypt.AESGCMDecrypt", "ciphertext too short", nil)
return nil, coreerr.E("crypt.AESGCMDecrypt", "ciphertext too short", nil)
}
nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := aead.Open(nil, nonce, encrypted, nil)
if err != nil {
return nil, core.E("crypt.AESGCMDecrypt", "failed to decrypt", err)
return nil, coreerr.E("crypt.AESGCMDecrypt", "failed to decrypt", err)
}
return plaintext, nil

View file

@ -161,17 +161,17 @@ Severity is low: an attacker with read access to process memory already has full
access to the process. The Go runtime does not guarantee memory zeroing and
GC-managed runtimes inherently have this limitation.
### Finding F3: Empty ScopedRepos Bypasses Scope Check on Tier 2 (Medium) — Open
### Finding F3: Empty ScopedRepos Bypasses Scope Check on Tier 2 (Medium) — RESOLVED
In `policy.go`, the repo scope check is conditioned on `len(agent.ScopedRepos) > 0`.
A Tier 2 agent with empty `ScopedRepos` (nil or `[]string{}`) is treated as
unrestricted rather than as having no access. If an admin registers a Tier 2
agent without explicitly setting `ScopedRepos`, it gets access to all repositories
for repo-scoped capabilities (`repo.push`, `pr.create`, `pr.merge`, `secrets.read`).
In `policy.go`, repo-scoped capability access previously skipped checks when
`len(agent.ScopedRepos) == 0`.
A Tier 2 agent with empty `ScopedRepos` (nil or `[]string{}`) was previously treated as
unrestricted rather than as having no access.
Potential remediation: treat empty `ScopedRepos` as no access for Tier 2 agents,
requiring explicit `["*"]` or `["org/**"]` for unrestricted access. This is a
design decision with backward-compatibility implications.
Resolved by requiring an explicit scope for repo-scoped capabilities:
- `[]string{}` / `nil` now denies all repo-scoped access by default.
- `[]string{"*"}` grants unrestricted repo access.
- Pattern matching with `host-uk/*` and `host-uk/**` still applies as before.
### Finding F4: `go vet` Clean — Passed
@ -224,8 +224,6 @@ callers that need structured logs should wrap or replace the cleanup goroutine.
`crypt/chachapoly` into a single implementation.
- **Hardware key backends**: implement `HardwareKey` for PKCS#11 (via
`miekg/pkcs11` or `ThalesIgnite/crypto11`) and YubiKey (via `go-piv`).
- **Resolve Finding F3**: require explicit wildcard for unrestricted Tier 2
access; treat empty `ScopedRepos` as no-access.
- **Structured logging**: replace `fmt.Printf` in `StartCleanup` with an
`slog.Logger` option on `Authenticator`.
- **Rate limiting enforcement**: the `Agent.RateLimit` field is stored in the

21
go.mod
View file

@ -1,21 +1,24 @@
module forge.lthn.ai/core/go-crypt
module dappco.re/go/core/crypt
go 1.26.0
require (
forge.lthn.ai/core/cli v0.3.5
forge.lthn.ai/core/go v0.3.1
forge.lthn.ai/core/go-i18n v0.1.6
forge.lthn.ai/core/go-io v0.1.5
forge.lthn.ai/core/go-log v0.0.4
forge.lthn.ai/core/go-store v0.1.7
dappco.re/go/core v0.5.0
dappco.re/go/core/i18n v0.2.0
dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0
forge.lthn.ai/core/cli v0.3.7
forge.lthn.ai/core/go-store v0.1.10
github.com/ProtonMail/go-crypto v1.4.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.49.0
)
require (
forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go v0.3.2 // indirect
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
forge.lthn.ai/core/go-inference v0.1.7 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
@ -53,5 +56,5 @@ require (
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.2 // indirect
modernc.org/sqlite v1.47.0 // indirect
)

34
go.sum
View file

@ -1,17 +1,23 @@
forge.lthn.ai/core/cli v0.3.5 h1:P7yK0DmSA1QnUMFuCjJZf/fk/akKPIxopQ6OwD8Sar8=
forge.lthn.ai/core/cli v0.3.5/go.mod h1:SeArHx+hbpX5iZqgASCD7Q1EDoc6uaaGiGBotmNzIx4=
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
forge.lthn.ai/core/go-i18n v0.1.6 h1:Z9h6sEZsgJmWlkkq3ZPZyfgWipeeqN5lDCpzltpamHU=
forge.lthn.ai/core/go-i18n v0.1.6/go.mod h1:C6CbwdN7sejTx/lbutBPrxm77b8paMHBO6uHVLHOdqQ=
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-store v0.1.7 h1:M1lA+YKX6NR+g5EzXkKjrTNRznGz5nypYahvPlbYVdQ=
forge.lthn.ai/core/go-store v0.1.7/go.mod h1:8HSEYfcU9tuivAzBz3i0FLBV0ls44QzXnlcd7cqL6PA=
forge.lthn.ai/core/go-store v0.1.10 h1:JLyf8xMR3V6PfBAW1kv6SJeHsYY93LacEBpTFW657qE=
forge.lthn.ai/core/go-store v0.1.10/go.mod h1:VNnHh94TMD3+L+sSgvxn0GHtDKhJR8FD6JiuIuRtjuk=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@ -133,8 +139,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View file

@ -6,7 +6,7 @@ import (
"sync"
"time"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// ApprovalStatus represents the state of an approval request.
@ -151,8 +151,8 @@ func (q *ApprovalQueue) Get(id string) *ApprovalRequest {
return nil
}
// Return a copy to prevent mutation.
copy := *req
return &copy
snapshot := *req
return &snapshot
}
// Pending returns all requests with ApprovalPending status.

View file

@ -7,7 +7,7 @@ import (
"sync"
"time"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// AuditEntry records a single policy evaluation for compliance.

View file

@ -6,7 +6,7 @@ import (
"io"
"os"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// PolicyConfig is the JSON-serialisable representation of a trust policy.
@ -34,12 +34,21 @@ func LoadPoliciesFromFile(path string) ([]Policy, error) {
// LoadPolicies reads JSON from a reader and returns parsed policies.
func LoadPolicies(r io.Reader) ([]Policy, error) {
const op = "trust.LoadPolicies"
var cfg PoliciesConfig
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil {
return nil, coreerr.E("trust.LoadPolicies", "failed to decode JSON", err)
return nil, coreerr.E(op, "failed to decode JSON", err)
}
// Reject trailing data after the decoded value
var extra json.RawMessage
if err := dec.Decode(&extra); err != io.EOF {
return nil, coreerr.E(op, "unexpected trailing data in JSON", nil)
}
return convertPolicies(cfg)
}

View file

@ -5,7 +5,7 @@ import (
"slices"
"strings"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// Policy defines the access rules for a given trust tier.
@ -117,9 +117,9 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
// Check if capability is allowed.
for _, allowed := range policy.Allowed {
if allowed == cap {
// For repo-scoped capabilities, verify repo access.
if isRepoScoped(cap) && len(agent.ScopedRepos) > 0 {
if !repoAllowed(agent.ScopedRepos, repo) {
// For repo-scoped capabilities, verify repo access for restricted tiers.
if isRepoScoped(cap) && agent.Tier != TierFull {
if len(agent.ScopedRepos) == 0 || !repoAllowed(agent.ScopedRepos, repo) {
return EvalResult{
Decision: Deny,
Agent: agentName,
@ -247,6 +247,11 @@ func matchScope(pattern, repo string) bool {
return true
}
// Star means unrestricted access for all repos.
if pattern == "*" {
return true
}
// Check for wildcard patterns.
if !strings.Contains(pattern, "*") {
return false

View file

@ -270,34 +270,49 @@ func TestDefaultRateLimit(t *testing.T) {
// --- Phase 0 Additions ---
// TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll verifies that a Tier 2
// agent with empty ScopedRepos is treated as "unrestricted" for repo-scoped
// capabilities. NOTE: This is a potential security concern documented in
// FINDINGS.md — empty ScopedRepos bypasses the repo scope check entirely.
func TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll(t *testing.T) {
// TestEvaluate_Bad_Tier2EmptyScopedReposDeniesAll verifies that an empty
// scoped-repo list blocks repo-scoped capabilities by default.
func TestEvaluate_Bad_Tier2EmptyScopedReposDeniesAll(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "Hypnos",
Tier: TierVerified,
ScopedRepos: []string{}, // empty — currently means "unrestricted"
ScopedRepos: []string{},
}))
pe := NewPolicyEngine(r)
// Current behaviour: empty ScopedRepos skips scope check (len == 0)
result := pe.Evaluate("Hypnos", CapPushRepo, "host-uk/core")
assert.Equal(t, Allow, result.Decision,
"empty ScopedRepos currently allows all repos (potential security finding)")
assert.Equal(t, Deny, result.Decision,
"empty ScopedRepos should deny repo-scoped operations by default")
result = pe.Evaluate("Hypnos", CapReadSecrets, "host-uk/core")
assert.Equal(t, Allow, result.Decision)
assert.Equal(t, Deny, result.Decision)
result = pe.Evaluate("Hypnos", CapCreatePR, "host-uk/core")
assert.Equal(t, Allow, result.Decision)
// Non-repo-scoped capabilities should still work
result = pe.Evaluate("Hypnos", CapCreateIssue, "")
assert.Equal(t, Allow, result.Decision)
result = pe.Evaluate("Hypnos", CapCommentIssue, "")
}
func TestEvaluate_Good_Tier2WildcardAllowsAll(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "Hydrus",
Tier: TierVerified,
ScopedRepos: []string{"*"},
}))
pe := NewPolicyEngine(r)
result := pe.Evaluate("Hydrus", CapPushRepo, "host-uk/core")
assert.Equal(t, Allow, result.Decision)
result = pe.Evaluate("Hydrus", CapReadSecrets, "host-uk/any")
assert.Equal(t, Allow, result.Decision)
result = pe.Evaluate("Hydrus", CapCreateIssue, "")
assert.Equal(t, Allow, result.Decision)
result = pe.Evaluate("Hydrus", CapCommentIssue, "")
assert.Equal(t, Allow, result.Decision)
}

View file

@ -13,6 +13,11 @@ func TestMatchScope_Good_ExactMatch(t *testing.T) {
assert.True(t, matchScope("host-uk/core", "host-uk/core"))
}
func TestMatchScope_Good_StarWildcard(t *testing.T) {
assert.True(t, matchScope("*", "host-uk/core"))
assert.True(t, matchScope("*", "core/php/sub"))
}
func TestMatchScope_Good_SingleWildcard(t *testing.T) {
assert.True(t, matchScope("core/*", "core/php"))
assert.True(t, matchScope("core/*", "core/go-crypt"))

View file

@ -16,7 +16,7 @@ import (
"sync"
"time"
coreerr "forge.lthn.ai/core/go-log"
coreerr "dappco.re/go/core/log"
)
// Tier represents an agent's trust level in the system.
@ -71,7 +71,9 @@ type Agent struct {
Name string
// Tier is the agent's trust level.
Tier Tier
// ScopedRepos limits repo access for Tier 2 agents. Empty means no repo access.
// ScopedRepos limits repo access for Tier 2 agents.
// Empty means no repo access.
// Use ["*"] for unrestricted repo scope.
// Tier 3 agents ignore this field (they have access to all repos).
ScopedRepos []string
// RateLimit is the maximum requests per minute. 0 means unlimited.