capability: agent privileged-capability self-probe (manifest + build-test + runtime snapshot) v0.44.0
New internal/capability: Manifest of required sudo -n grants + Prober that LISTS each via 'sudo -n -l' (never executes) + binary-exists check → ok/degraded snapshot on the hub report. Build-time test asserts manifest⊆sudoers (red-proof: dropping lxc-info FAILs the gate). Startup logs N/N ok + ERROR per degraded. Serve-degraded; no allowlist change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user