251 lines
6.9 KiB
Go
251 lines
6.9 KiB
Go
package policy
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Parse extrae la política del contenido de agent.md.
|
|
//
|
|
// Por qué un bloque dedicado: agent.md es Markdown libre y puede contener
|
|
// ejemplos como "EXECUTION_MODE=unrestricted" dentro de la documentación.
|
|
// Si se interpretara cualquier línea KEY=VALUE, un ejemplo podría conceder
|
|
// permisos por accidente. Por eso solo se leen directivas dentro de un bloque
|
|
// de código cercado cuyo info string es exactamente "policy":
|
|
//
|
|
// ```policy
|
|
// EXECUTION_MODE=privileged
|
|
// ALLOW_NETWORK=true
|
|
// ```
|
|
//
|
|
// Reglas:
|
|
// - Sin bloque: política por defecto (guarded, nada permitido).
|
|
// - Más de un bloque: error, porque la intención sería ambigua.
|
|
// - Directiva repetida o valor inválido: error (no se adivina).
|
|
// - Directiva desconocida: se ignora y se reporta en Policy.Unknown.
|
|
func Parse(markdown string) (*Policy, error) {
|
|
block, found, err := extractPolicyBlock(markdown)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := Default()
|
|
if !found {
|
|
return p, nil
|
|
}
|
|
p.Declared = true
|
|
|
|
seen := map[string]int{}
|
|
sc := bufio.NewScanner(strings.NewReader(block.body))
|
|
lineNo := block.startLine
|
|
for sc.Scan() {
|
|
lineNo++
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent.md line %d: expected KEY=VALUE in policy block", lineNo)
|
|
}
|
|
key = strings.ToUpper(strings.TrimSpace(key))
|
|
value = strings.TrimSpace(value)
|
|
if prev, dup := seen[key]; dup {
|
|
return nil, fmt.Errorf("agent.md line %d: duplicate directive %s (first on line %d)", lineNo, key, prev)
|
|
}
|
|
seen[key] = lineNo
|
|
|
|
if err := p.apply(key, value); err != nil {
|
|
return nil, fmt.Errorf("agent.md line %d: %w", lineNo, err)
|
|
}
|
|
}
|
|
p.sortedUnknown()
|
|
return p, nil
|
|
}
|
|
|
|
// apply interpreta una única directiva sobre la política.
|
|
func (p *Policy) apply(key, value string) error {
|
|
switch {
|
|
case key == directiveMode:
|
|
m := Mode(strings.ToLower(value))
|
|
switch m {
|
|
case ModeGuarded, ModePrivileged, ModeUnrestricted:
|
|
p.Mode = m
|
|
default:
|
|
return fmt.Errorf("invalid %s %q (want guarded, privileged or unrestricted)", key, value)
|
|
}
|
|
case key == directiveBackup:
|
|
b, err := parseBool(value)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
p.BackupBeforeSystemEdit = b
|
|
case key == directiveTimeout:
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil || n <= 0 {
|
|
return fmt.Errorf("%s must be a positive number of seconds, got %q", key, value)
|
|
}
|
|
p.DefaultCommandTimeout = time.Duration(n) * time.Second
|
|
case key == directiveMaxSteps:
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil || n <= 0 || n > 1000 {
|
|
return fmt.Errorf("%s must be between 1 and 1000, got %q", key, value)
|
|
}
|
|
p.MaxSteps = n
|
|
case key == directiveMaxDL:
|
|
n, err := parseSize(value)
|
|
if err != nil || n <= 0 {
|
|
return fmt.Errorf("%s must be a positive size in bytes (suffixes K, M, G allowed), got %q", key, value)
|
|
}
|
|
p.MaxDownloadSize = n
|
|
case key == directiveTaskOrder:
|
|
for _, name := range strings.Split(value, ",") {
|
|
if name = strings.TrimSpace(name); name != "" {
|
|
p.TaskOrder = append(p.TaskOrder, name)
|
|
}
|
|
}
|
|
case knownCapabilities[Capability(key)]:
|
|
b, err := parseBool(value)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
p.caps[Capability(key)] = b
|
|
default:
|
|
p.Unknown = append(p.Unknown, key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseBool acepta solo valores inequívocos. "yes"/"1" etc. se aceptan para
|
|
// comodidad, pero cualquier otra cosa es un error en lugar de un false
|
|
// silencioso, para que un error tipográfico no pase inadvertido.
|
|
func parseBool(v string) (bool, error) {
|
|
switch strings.ToLower(v) {
|
|
case "true", "yes", "1", "on":
|
|
return true, nil
|
|
case "false", "no", "0", "off":
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("invalid boolean %q", v)
|
|
}
|
|
|
|
type fencedBlock struct {
|
|
body string
|
|
startLine int // número de línea de la cerca de apertura
|
|
}
|
|
|
|
// extractPolicyBlock localiza el único bloque ```policy del documento.
|
|
//
|
|
// Se recorren todos los bloques cercados respetando su longitud de cerca
|
|
// (``` o más, o ~~~), de modo que un "```policy" que aparezca como texto
|
|
// dentro de otro bloque de ejemplo no se confunda con un bloque real.
|
|
func extractPolicyBlock(md string) (fencedBlock, bool, error) {
|
|
lines := strings.Split(md, "\n")
|
|
var (
|
|
result fencedBlock
|
|
found bool
|
|
inFence bool
|
|
fence string
|
|
isPolicy bool
|
|
body strings.Builder
|
|
openLine int
|
|
)
|
|
for i, raw := range lines {
|
|
line := strings.TrimRight(raw, "\r")
|
|
trimmed := strings.TrimLeft(line, " ")
|
|
indent := len(line) - len(trimmed)
|
|
|
|
if !inFence {
|
|
if indent > 3 {
|
|
continue
|
|
}
|
|
marker, info, ok := openingFence(trimmed)
|
|
if !ok {
|
|
continue
|
|
}
|
|
inFence, fence, openLine = true, marker, i+1
|
|
fields := strings.Fields(info)
|
|
isPolicy = len(fields) > 0 && strings.EqualFold(fields[0], "policy")
|
|
body.Reset()
|
|
continue
|
|
}
|
|
|
|
// Dentro de un bloque: ¿es la cerca de cierre?
|
|
if indent <= 3 && isClosingFence(trimmed, fence) {
|
|
if isPolicy {
|
|
if found {
|
|
return fencedBlock{}, false, fmt.Errorf("agent.md: multiple policy blocks (lines %d and %d); keep only one", result.startLine, openLine)
|
|
}
|
|
result = fencedBlock{body: body.String(), startLine: openLine}
|
|
found = true
|
|
}
|
|
inFence = false
|
|
continue
|
|
}
|
|
if isPolicy {
|
|
body.WriteString(line)
|
|
body.WriteByte('\n')
|
|
}
|
|
}
|
|
if inFence && isPolicy {
|
|
return fencedBlock{}, false, fmt.Errorf("agent.md line %d: unterminated policy block", openLine)
|
|
}
|
|
return result, found, nil
|
|
}
|
|
|
|
// openingFence detecta ``` o ~~~ (3 o más) y devuelve el marcador y el info string.
|
|
func openingFence(s string) (marker, info string, ok bool) {
|
|
if len(s) < 3 || (s[0] != '`' && s[0] != '~') {
|
|
return "", "", false
|
|
}
|
|
c := s[0]
|
|
n := 0
|
|
for n < len(s) && s[n] == c {
|
|
n++
|
|
}
|
|
if n < 3 {
|
|
return "", "", false
|
|
}
|
|
info = strings.TrimSpace(s[n:])
|
|
if c == '`' && strings.Contains(info, "`") {
|
|
return "", "", false
|
|
}
|
|
return s[:n], info, true
|
|
}
|
|
|
|
// isClosingFence: mismo carácter, al menos la misma longitud y nada más.
|
|
func isClosingFence(s, open string) bool {
|
|
s = strings.TrimRight(s, " \t")
|
|
if len(s) < len(open) {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] != open[0] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// parseSize acepta bytes con sufijo opcional K, M o G (potencias de 1024).
|
|
func parseSize(v string) (int64, error) {
|
|
v = strings.ToUpper(strings.TrimSpace(v))
|
|
mult := int64(1)
|
|
for suffix, m := range map[string]int64{"K": 1 << 10, "M": 1 << 20, "G": 1 << 30, "KB": 1 << 10, "MB": 1 << 20, "GB": 1 << 30, "KIB": 1 << 10, "MIB": 1 << 20, "GIB": 1 << 30} {
|
|
if strings.HasSuffix(v, suffix) && len(suffix) > 0 {
|
|
if _, err := strconv.ParseInt(strings.TrimSuffix(v, suffix), 10, 64); err == nil {
|
|
mult = m
|
|
v = strings.TrimSuffix(v, suffix)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return n * mult, nil
|
|
}
|