Files
felhom-agent/internal/capability/manifest_test.go
T
admin 8a4ccab3e6 controllerswap: stdin tee write + narrow FELHOM_CONTROLLERSWAP grants (non-root, v0.45.0)
writeImage drops bash -c/printf for GuestExecStdin(img+\n -> tee /etc/felhom-controller-image);
new Runner.RunStdin/GuestExecStdin route stdin through the fenced sudo -n runner. 5 narrow,
auditable sudoers grants (no general pct exec, no bash -c) + capability manifest entries (Critical)
so the self-probe watches them and the build-test asserts coverage (companion red-proof). No
controller change; swap orchestration/rollback/state unchanged. Spike GO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
2026-06-29 19:42:30 +02:00

219 lines
7.0 KiB
Go

package capability
import (
"os"
"regexp"
"strings"
"testing"
)
// sudoersPath is the in-repo allowlist, relative to this test file (internal/capability/).
const sudoersPath = "../../configs/felhom-agent.sudoers"
// parseSudoersEntries returns every command pattern from the Cmnd_Alias blocks, with the sudoers
// escapes (`\,` `\:`) unescaped. It joins continuation lines and splits the alias RHS on commas
// that are NOT backslash-escaped (escaped commas are literal arg chars, e.g. the lvs `-o` list).
func parseSudoersEntries(t *testing.T, text string) []string {
t.Helper()
// 1. Collapse line continuations, keeping only Cmnd_Alias RHS text.
var rhs strings.Builder
lines := strings.Split(text, "\n")
inAlias := false
for _, ln := range lines {
trimmed := strings.TrimSpace(ln)
if strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(trimmed, "Cmnd_Alias ") {
inAlias = true
if eq := strings.IndexByte(trimmed, '='); eq >= 0 {
trimmed = trimmed[eq+1:]
}
} else if !inAlias {
continue
}
// The final NOPASSWD line ("felhom-agent ALL=...") ends the alias region.
if strings.Contains(trimmed, "ALL=(") {
inAlias = false
continue
}
cont := strings.HasSuffix(trimmed, "\\")
rhs.WriteString(strings.TrimSuffix(trimmed, "\\"))
rhs.WriteString(" ")
if !cont {
// A non-continued line is the last entry of this alias. Emit a comma so it does not
// merge with the next alias's first entry when all RHS text is concatenated.
rhs.WriteString(", ")
inAlias = false
}
}
// 2. Split on unescaped commas → individual command entries.
raw := rhs.String()
var entries []string
var cur strings.Builder
for i := 0; i < len(raw); i++ {
c := raw[i]
if c == '\\' && i+1 < len(raw) {
cur.WriteByte(raw[i+1]) // unescape: keep the next char literally (\, → , ; \: → :)
i++
continue
}
if c == ',' {
entries = appendTrimmed(entries, cur.String())
cur.Reset()
continue
}
cur.WriteByte(c)
}
entries = appendTrimmed(entries, cur.String())
return entries
}
func appendTrimmed(entries []string, s string) []string {
if t := strings.Join(strings.Fields(s), " "); t != "" {
return append(entries, t)
}
return entries
}
// globToRegex translates a sudoers fnmatch pattern to an anchored regex. It is NOT a perfect sudo
// emulator — it only needs to catch a removed/renamed grant (the real failure mode). `*` → `.*`,
// `[...]` char classes pass through (valid regex), regex metachars are escaped.
func globToRegex(pat string) *regexp.Regexp {
var b strings.Builder
b.WriteString("^")
for i := 0; i < len(pat); i++ {
c := pat[i]
switch {
case c == '*':
b.WriteString(".*")
case c == '[': // copy the char class verbatim (valid in regex too)
if j := strings.IndexByte(pat[i:], ']'); j > 0 {
b.WriteString(pat[i : i+j+1])
i += j
continue
}
b.WriteString("\\[")
case strings.IndexByte(`.+()|{}^$\?`, c) >= 0:
b.WriteByte('\\')
b.WriteByte(c)
default:
b.WriteByte(c)
}
}
b.WriteString("$")
return regexp.MustCompile(b.String())
}
// matchesAny reports whether cmdline matches at least one sudoers entry pattern.
func matchesAny(cmdline string, entries []string) bool {
for _, e := range entries {
if globToRegex(e).MatchString(cmdline) {
return true
}
}
return false
}
// TestManifestCoveredBySudoers is the headline build-time gate: EVERY manifest capability's
// representative command line must be permitted by at least one sudoers pattern. This is exactly
// the check that would have caught the lxc-info / make-private grants being dropped at the
// 2026-06-28 cutover — in CI, before shipping.
func TestManifestCoveredBySudoers(t *testing.T) {
data, err := os.ReadFile(sudoersPath)
if err != nil {
t.Fatalf("read sudoers %s: %v", sudoersPath, err)
}
entries := parseSudoersEntries(t, string(data))
if len(entries) < 20 {
t.Fatalf("parsed only %d sudoers entries — parser likely broke", len(entries))
}
for _, c := range Manifest() {
cmdline := strings.TrimSpace(c.Binary + " " + strings.Join(c.ReprArgs, " "))
if !matchesAny(cmdline, entries) {
t.Errorf("capability %q (%s) NOT covered by any sudoers grant:\n %s",
c.Name, c.Feature, cmdline)
}
}
}
// TestRedProof_DroppedGrantFailsCheck is the companion red-proof: with the lxc-info line removed
// from an in-memory copy of the sudoers, the coverage check for guest-init-pid MUST fail. Proves
// the build gate actually catches the regression (a green test that can never go red is hollow).
func TestRedProof_DroppedGrantFailsCheck(t *testing.T) {
data, err := os.ReadFile(sudoersPath)
if err != nil {
t.Fatalf("read sudoers: %v", err)
}
// Drop the lxc-info grant line.
var kept []string
for _, ln := range strings.Split(string(data), "\n") {
if strings.Contains(ln, "lxc-info") {
continue
}
kept = append(kept, ln)
}
mutated := strings.Join(kept, "\n")
if strings.Contains(mutated, "lxc-info") {
t.Fatal("setup: lxc-info line not removed")
}
entries := parseSudoersEntries(t, mutated)
var guestInit Capability
for _, c := range Manifest() {
if c.Name == "guest-init-pid" {
guestInit = c
}
}
if guestInit.Name == "" {
t.Fatal("manifest missing guest-init-pid")
}
cmdline := guestInit.Binary + " " + strings.Join(guestInit.ReprArgs, " ")
if matchesAny(cmdline, entries) {
t.Errorf("red-proof FAILED: guest-init-pid still matches after dropping the lxc-info grant — the build gate would NOT catch the regression")
}
// Sanity: the UNMUTATED file MUST cover it (so the failure above is specific to the drop).
full := parseSudoersEntries(t, string(data))
if !matchesAny(cmdline, full) {
t.Errorf("guest-init-pid should be covered by the real sudoers")
}
}
// TestRedProof_DroppedControllerSwapTeeFailsCheck is the companion red-proof for the v0.45.0
// FELHOM_CONTROLLERSWAP grants: with the `tee /etc/felhom-controller-image` line removed, the
// controllerswap-write capability MUST be reported uncovered. Proves the build gate watches the new
// swap write grant (so dropping it can't ship a non-root agent that silently can't auto-update).
func TestRedProof_DroppedControllerSwapTeeFailsCheck(t *testing.T) {
data, err := os.ReadFile(sudoersPath)
if err != nil {
t.Fatalf("read sudoers: %v", err)
}
var kept []string
for _, ln := range strings.Split(string(data), "\n") {
if strings.Contains(ln, "tee /etc/felhom-controller-image") {
continue
}
kept = append(kept, ln)
}
mutated := strings.Join(kept, "\n")
entries := parseSudoersEntries(t, mutated)
var write Capability
for _, c := range Manifest() {
if c.Name == "controllerswap-write" {
write = c
}
}
if write.Name == "" {
t.Fatal("manifest missing controllerswap-write")
}
cmdline := write.Binary + " " + strings.Join(write.ReprArgs, " ")
if matchesAny(cmdline, entries) {
t.Errorf("red-proof FAILED: controllerswap-write still matches after dropping the tee grant")
}
if full := parseSudoersEntries(t, string(data)); !matchesAny(cmdline, full) {
t.Errorf("controllerswap-write should be covered by the real sudoers")
}
}