8a4ccab3e6
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
219 lines
7.0 KiB
Go
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")
|
|
}
|
|
}
|