- The drafter no longer grows one conversation. Each request is the rules and facts, the model's latest template, what is wrong now and one line per earlier round, so long builds can't push the facts out of the model's 32K context (Ollama drops the oldest part silently). The image's scripts in the facts are capped at 24000 characters in all, and diagnosis check results at 16000. - Every model call logs its prompt and answer tokens, with a warning near the context limit. - unsafeCommands: a draft whose startup command or installConfig pipes a download into a shell, decodes base64, uses eval, raw network tools or /dev/tcp, sudo/su, setuid bits, writes /etc, touches cron/systemd/firewall, or looks like a miner goes back to the model. AUTHORING.md has the rule. - The PR summary lists every address nodes would download from under "Review carefully", marks the measured checks as measured, and labels the research notes as the model's reading, not checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
402 lines
14 KiB
Go
402 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Checks a booting draft can still fail: things that only go wrong once an owner changes a setting, so
|
|
// neither the validator nor a boot test with the defaults sees them.
|
|
|
|
// splitSettings finds text settings that reach the server's command line unquoted, so a value with spaces
|
|
// ("GBRHosting - My Server") arrives as several arguments and the game only sees the first word. Covers
|
|
// direct use (-name ${SERVER_NAME}) and the common ARGS="-name ${SERVER_NAME}"; ./server $ARGS, where the
|
|
// quotes in the assignment don't survive the unquoted $ARGS.
|
|
func splitSettings(game map[string]any) []string {
|
|
startup, _ := game["defaultStartupCommand"].(string)
|
|
if startup == "" {
|
|
return nil
|
|
}
|
|
textVars := map[string]string{} // env name -> setting key
|
|
for key, p := range schemaProperties(game) {
|
|
if prop, _ := p.(map[string]any); prop["type"] == "string" {
|
|
textVars[envName(key)] = key
|
|
}
|
|
}
|
|
if len(textVars) == 0 {
|
|
return nil
|
|
}
|
|
s := scanShell(unwrapShell(startup))
|
|
|
|
split := map[string]string{} // setting key -> how it gets split
|
|
var reach func(holder string, seen map[string]bool) []string
|
|
reach = func(holder string, seen map[string]bool) []string {
|
|
if seen[holder] {
|
|
return nil
|
|
}
|
|
seen[holder] = true
|
|
var out []string
|
|
for _, v := range s.held[holder] {
|
|
if _, ok := textVars[v]; ok {
|
|
out = append(out, v)
|
|
}
|
|
out = append(out, reach(v, seen)...)
|
|
}
|
|
return out
|
|
}
|
|
for _, e := range s.uses {
|
|
if e.quoted {
|
|
continue
|
|
}
|
|
if key, ok := textVars[e.name]; ok {
|
|
split[key] = "${" + e.name + "} is not in double quotes"
|
|
}
|
|
if len(s.held[e.name]) > 0 {
|
|
for _, v := range reach(e.name, map[string]bool{}) {
|
|
if _, done := split[textVars[v]]; !done {
|
|
split[textVars[v]] = fmt.Sprintf("${%s} is inside $%s, which is used without double quotes", v, e.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var problems []string
|
|
for _, key := range sortedKeysString(split) {
|
|
problems = append(problems, fmt.Sprintf("setting %s is split into words on the command line (%s): a value with spaces like "+
|
|
"\"GBRHosting - My Server\" reaches the game as several arguments. Quote it where it is used (-name \"${%s}\"), or build "+
|
|
"optional arguments in a bash array: args=(-name \"$%s\"); [ -n \"$X\" ] && args+=(-x \"$X\"); ./server \"${args[@]}\"",
|
|
key, split[key], envName(key), envName(key)))
|
|
}
|
|
return problems
|
|
}
|
|
|
|
var shellWrap = regexp.MustCompile(`^\s*(?:ba)?sh\s+-c\s+'(.*)'\s*$`)
|
|
|
|
// unwrapShell looks inside a startup command that is one bash -c '...', which would otherwise be
|
|
// all single-quoted text.
|
|
func unwrapShell(cmd string) string {
|
|
if m := shellWrap.FindStringSubmatch(cmd); m != nil && !strings.Contains(m[1], "'") {
|
|
return m[1]
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
type shellUse struct {
|
|
name string
|
|
quoted bool
|
|
}
|
|
|
|
type shellScan struct {
|
|
uses []shellUse // expansions where the value is used: words, arguments
|
|
held map[string][]string // variable -> variables its assigned value contains
|
|
}
|
|
|
|
var (
|
|
assignWord = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\+?=`)
|
|
varName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*`)
|
|
nestedVar = regexp.MustCompile(`\$\{?([A-Za-z_][A-Za-z0-9_]*)`)
|
|
)
|
|
|
|
// scanShell is just enough of a shell parser for splitSettings: quotes, $VAR / ${VAR...} expansions, and
|
|
// assignments (NAME=..., NAME+=..., NAME=(...) arrays).
|
|
func scanShell(cmd string) *shellScan {
|
|
s := &shellScan{held: map[string][]string{}}
|
|
var single, double, wordStart = false, false, true
|
|
assign, inArray := "", false
|
|
record := func(name string, quoted bool) {
|
|
switch {
|
|
case assign != "" && !inArray:
|
|
// NAME="...$X..." : no splitting yet, but $NAME carries X along
|
|
s.held[assign] = append(s.held[assign], name)
|
|
case inArray && quoted:
|
|
s.held[assign] = append(s.held[assign], name) // safe unless the array is expanded unquoted
|
|
default:
|
|
s.uses = append(s.uses, shellUse{name, quoted})
|
|
}
|
|
}
|
|
for i := 0; i < len(cmd); i++ {
|
|
c := cmd[i]
|
|
if wordStart && !single && !double {
|
|
if m := assignWord.FindString(cmd[i:]); m != "" {
|
|
assign = strings.TrimRight(strings.TrimSuffix(m, "="), "+")
|
|
i += len(m)
|
|
if i < len(cmd) && cmd[i] == '(' {
|
|
inArray = true
|
|
} else {
|
|
i--
|
|
}
|
|
wordStart = false
|
|
continue
|
|
}
|
|
}
|
|
wordStart = false
|
|
switch {
|
|
case c == '\\' && !single:
|
|
i++
|
|
case c == '\'' && !double:
|
|
single = !single
|
|
case c == '"' && !single:
|
|
double = !double
|
|
case c == '$' && !single && i+1 < len(cmd):
|
|
if cmd[i+1] == '{' {
|
|
end := matchingBrace(cmd, i+1)
|
|
body := cmd[i+2 : end]
|
|
name := varName.FindString(strings.TrimLeft(body, "#!"))
|
|
if name != "" {
|
|
record(name, double)
|
|
}
|
|
// Defaults can hold more expansions: ${A:-${B}}
|
|
for _, m := range nestedVar.FindAllStringSubmatch(body[len(name):], -1) {
|
|
record(m[1], double)
|
|
}
|
|
i = end
|
|
} else if name := varName.FindString(cmd[i+1:]); name != "" {
|
|
record(name, double)
|
|
i += len(name)
|
|
}
|
|
case single || double:
|
|
case inArray && c == ')':
|
|
inArray, assign = false, ""
|
|
case inArray:
|
|
case c == ' ' || c == '\t' || c == '\n' || c == ';' || c == '&' || c == '|' || c == '(' || c == ')':
|
|
assign, wordStart = "", true
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func matchingBrace(s string, open int) int {
|
|
depth := 0
|
|
for i := open; i < len(s); i++ {
|
|
switch s[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return len(s) - 1
|
|
}
|
|
|
|
var (
|
|
portSetting = regexp.MustCompile(`(?i)(port$|(^|_)port(_|$))`)
|
|
notAPort = regexp.MustCompile(`(?i)(sup|re|ex|im|trans)port$`)
|
|
// Reached over the Docker network, never published (AUTHORING.md)
|
|
internalPort = regexp.MustCompile(`(?i)rcon|rest|api|admin|telnet|web|http|metrics`)
|
|
)
|
|
|
|
// portSettings checks settings that set a port players connect to against defaultPorts. The node always
|
|
// publishes defaultPorts' container ports (moving only the host side when servers share a node), so a
|
|
// game listening anywhere else can't be reached: a default that isn't published is broken from the
|
|
// start, and an editable one breaks the moment an owner changes it.
|
|
func portSettings(game map[string]any) []string {
|
|
published := map[int]bool{}
|
|
ports, _ := game["defaultPorts"].([]any)
|
|
for _, p := range ports {
|
|
n, _ := strconv.Atoi(strings.Split(containerPort(fmt.Sprint(p)), "/")[0])
|
|
published[n] = true
|
|
}
|
|
defaults, _ := game["defaultConfig"].(map[string]any)
|
|
props := schemaProperties(game)
|
|
var problems []string
|
|
keys := make([]string, 0, len(defaults))
|
|
for k := range defaults {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if !portSetting.MatchString(key) || notAPort.MatchString(key) || internalPort.MatchString(key) {
|
|
continue
|
|
}
|
|
port, ok := portNumber(defaults[key])
|
|
if !ok {
|
|
continue // {PUBLIC_PORT_n} and other placeholders are filled per server
|
|
}
|
|
_, editable := props[key]
|
|
switch {
|
|
case !published[port]:
|
|
problems = append(problems, fmt.Sprintf("setting %s defaults to port %d, which defaultPorts does not publish: players can't reach it. "+
|
|
"Use one of the published ports, or add this port to defaultPorts if players connect to it", key, port))
|
|
case editable:
|
|
problems = append(problems, fmt.Sprintf("setting %s lets owners change the port, but the node always publishes container port %d "+
|
|
"(defaultPorts), so any other value makes the server unreachable. Don't make it a setting: remove %s from "+
|
|
"defaultConfig and configSchema and write %d directly in the startup command or config file. If the game must "+
|
|
"announce the port players use, give it {PUBLIC_PORT_%d} instead", key, port, key, port, port))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
func portNumber(v any) (int, bool) {
|
|
var s string
|
|
switch n := v.(type) {
|
|
case json.Number:
|
|
s = n.String()
|
|
case string:
|
|
s = n
|
|
case int, int64, float64:
|
|
s = fmt.Sprint(n)
|
|
default:
|
|
return 0, false
|
|
}
|
|
p, err := strconv.Atoi(strings.TrimSpace(s))
|
|
return p, err == nil && p > 0 && p < 65536
|
|
}
|
|
|
|
var (
|
|
optionToken = regexp.MustCompile(`(?:^|[\s("'])(-{1,2}[A-Za-z][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*)`)
|
|
segmentSep = regexp.MustCompile(`;|&&|\|\||\||\n`)
|
|
testOps = map[string]bool{"eq": true, "ne": true, "lt": true, "gt": true, "le": true, "ge": true, "nt": true, "ot": true, "ef": true}
|
|
// Options of these are the shell's and its tools', not the game's
|
|
shellTools = map[string]bool{"mkdir": true, "cp": true, "mv": true, "ln": true, "rm": true, "chmod": true, "chown": true,
|
|
"wget": true, "curl": true, "tar": true, "unzip": true, "test": true, "[": true, "[[": true, "echo": true, "printf": true,
|
|
"sleep": true, "find": true, "sed": true, "grep": true, "cd": true, "export": true, "touch": true, "bash": true, "sh": true,
|
|
"cat": true, "head": true, "tail": true, "ls": true, "set": true, "ulimit": true, "env": true, "id": true}
|
|
)
|
|
|
|
// undocumentedOptions finds launch options in the startup command that none of the researched pages (nor
|
|
// the image's own scripts) mention. Servers silently ignore options they don't know, so a made-up one
|
|
// boots fine and passes every boot test while its setting does nothing.
|
|
func undocumentedOptions(game map[string]any, docs string, facts *imageFacts) []string {
|
|
if strings.TrimSpace(docs) == "" {
|
|
return nil
|
|
}
|
|
startup, _ := game["defaultStartupCommand"].(string)
|
|
corpus := docs
|
|
if facts != nil {
|
|
for _, s := range facts.Scripts {
|
|
corpus += "\n" + s
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
var missing []string
|
|
for _, opt := range launchOptions(unwrapShell(startup)) {
|
|
name := strings.TrimLeft(opt, "-")
|
|
key := strings.ToLower(name)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
documented := regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_])-{1,2}` + regexp.QuoteMeta(name) + `($|[^A-Za-z0-9_])`)
|
|
if !documented.MatchString(corpus) {
|
|
missing = append(missing, opt)
|
|
}
|
|
}
|
|
if len(missing) == 0 {
|
|
return nil
|
|
}
|
|
return []string{fmt.Sprintf("the startup command passes %s, which none of the pages researched for this game mention. "+
|
|
"Servers silently ignore options they don't know, so it boots fine but does nothing: remove it (and any setting that "+
|
|
"only feeds it), or spell it exactly as the documentation does", strings.Join(missing, ", "))}
|
|
}
|
|
|
|
// launchOptions lists the -options passed to the game, skipping the shell's own (mkdir -p, [ -n ... ]).
|
|
func launchOptions(cmd string) []string {
|
|
var out []string
|
|
for _, seg := range segmentSep.Split(cmd, -1) {
|
|
fields := strings.Fields(strings.TrimLeft(strings.TrimSpace(seg), "({"))
|
|
i := 0
|
|
for i < len(fields) && (fields[i] == "exec" || fields[i] == "then" || fields[i] == "do" || fields[i] == "else" ||
|
|
(assignWord.MatchString(fields[i]) && !strings.Contains(fields[i], "=("))) {
|
|
i++
|
|
}
|
|
if i == len(fields) {
|
|
continue
|
|
}
|
|
if word := path.Base(fields[i]); shellTools[word] {
|
|
continue
|
|
}
|
|
for _, m := range optionToken.FindAllStringSubmatch(strings.Join(fields[i:], " "), -1) {
|
|
name := strings.TrimLeft(m[1], "-")
|
|
if len(name) < 2 || testOps[name] {
|
|
continue
|
|
}
|
|
out = append(out, m[1])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Things a game server's startup or install never needs, and that a prompt-injected or careless model
|
|
// could write. Templates run on players' own machines, so a draft with any of them goes back to the model.
|
|
var unsafePatterns = []struct {
|
|
re *regexp.Regexp
|
|
what string
|
|
}{
|
|
{regexp.MustCompile(`(?i)(curl|wget)[^|;&\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b`), "pipes a download straight into a shell"},
|
|
{regexp.MustCompile(`(?i)base64\s+(-d|--decode)`), "decodes hidden (base64) content"},
|
|
{regexp.MustCompile(`(^|[^\w-])eval\s`), "runs text as a command (eval)"},
|
|
{regexp.MustCompile(`/dev/(tcp|udp)/`), "opens raw network connections"},
|
|
{regexp.MustCompile(`(^|[^\w-])(nc|ncat|netcat|socat)\s`), "uses a raw network tool"},
|
|
{regexp.MustCompile(`(^|[^\w-])(sudo|su)\s`), "tries to become another user"},
|
|
{regexp.MustCompile(`chmod\s+([0-7]*[4-7][0-7]{3}\b|[ugoa]*\+[rwx]*s)`), "sets setuid/setgid bits"},
|
|
{regexp.MustCompile(`(>|\btee\s+(-a\s+)?)\s*/etc/`), "writes to /etc"},
|
|
{regexp.MustCompile(`(?i)(^|[^\w-])(crontab|systemctl|iptables|mount|insmod|modprobe)\s`), "changes the machine itself"},
|
|
{regexp.MustCompile(`(?i)xmrig|minerd|stratum\+tcp|cryptonight`), "looks like a cryptocurrency miner"},
|
|
}
|
|
|
|
// unsafeCommands checks the commands a node runs for a template: the startup command and anything in
|
|
// installConfig (scripts, file downloads).
|
|
func unsafeCommands(game map[string]any) []string {
|
|
var problems []string
|
|
for _, text := range commandTexts(game) {
|
|
for _, p := range unsafePatterns {
|
|
if m := p.re.FindString(text); m != "" {
|
|
problems = append(problems, fmt.Sprintf("the template %s (%q). Templates run on players' own machines: a game "+
|
|
"server never needs that. Remove it; if the server needs a file, install it with installMethod or "+
|
|
"installConfig.files from the game's official source", p.what, strings.TrimSpace(m)))
|
|
}
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
var urlRe = regexp.MustCompile(`https?://[^\s"'<>)]+`)
|
|
|
|
// downloadURLs lists the addresses a template makes nodes download from, for the reviewer to check.
|
|
func downloadURLs(game map[string]any) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, text := range commandTexts(game) {
|
|
for _, u := range urlRe.FindAllString(text, -1) {
|
|
if !seen[u] {
|
|
seen[u] = true
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// commandTexts is everything in a template that ends up being run or fetched on a node.
|
|
func commandTexts(game map[string]any) []string {
|
|
var out []string
|
|
if s, _ := game["defaultStartupCommand"].(string); s != "" {
|
|
out = append(out, unwrapShell(s))
|
|
}
|
|
var walk func(v any)
|
|
walk = func(v any) {
|
|
switch t := v.(type) {
|
|
case string:
|
|
out = append(out, t)
|
|
case map[string]any:
|
|
for _, k := range sortedKeys(t) {
|
|
walk(t[k])
|
|
}
|
|
case []any:
|
|
for _, e := range t {
|
|
walk(e)
|
|
}
|
|
}
|
|
}
|
|
walk(game["installConfig"])
|
|
return out
|
|
}
|