package main // ops.go — the OP: a program the host wrote, tested on its own harness pool, // and asks this machine to run; and the two signed terms under which it may. // // An op is a directory: // // op.yaml the manifest — name, version, summary, privilege, interpreter, // params (a typed schema), effects (the FILES it may touch), // time_limit, undo // run the program, run by one of a fixed set of interpreters with a // fixed argv and the params as JSON on stdin // test/ what the host's build ran it against (not read here) // // Its identity is the sha256 of the directory's canonical form (opDirDigest): // sorted paths, each framed path\0mode\0size\0 then the bytes. One changed // byte is a different op. The pod computes the same (nac_ops.op_dir_digest). // // It reaches this box only WITH signed terms (op_sync): the terms name the // sha256, the bundle must hash to it, and the manifest must parse — a fixed // interpreter, absolute effects that are never "/", a time limit inside the // box's ceiling. The box holds it under /ops//. // // Two terms, both signed by the host's entity key (verified under the pinned // key like a store grant) and both carrying the person's approval: // // op_run "Do it once": this op, this machine, these params (carried as // the string the pod produced, hashed verbatim), expires ≤ 24 h, // one use. Purpose op_run|server|op|sha256|sha256(params)|expires // op_grant "From now on": this op, this machine, param BOUNDS, expires ≤ // 30 d, a use count; privilege user only — root is never reusable. // Held in ops.json beside grants.json. Purpose // op_grant|server|op|sha256|sha256(bounds)|expires|uses // // The executor (ops_exec_linux.go) runs the op inside an overlay sandbox and // diffs what it changed; a files op has FILE effects only, and an op whose // effects name anything else is refused at sync. An ACTION op (kind: action, // ops_action.go) is a ship step: its program runs unprivileged in a systemd // unit held to its declared destinations, and its root work is a few named // verbs the bastion does itself. Windows and macOS boxes refuse every op with // that sentence (ops_exec_other.go). import ( "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/gorilla/websocket" "gopkg.in/yaml.v3" ) const ( opBundleMaxBytes = 1 << 20 // the whole op directory, on the wire opTimeLimitMax = 10 * time.Minute // the box's ceiling on one run opRunTTLMax = 24 * time.Hour // an op_run's expiry may not exceed this opGrantTTLMax = 30 * 24 * time.Hour opGrantUsesMax = 1000 opParamsMaxBytes = 64 << 10 opOutputMaxBytes = 256 << 10 // captured stdout+stderr per run opDiffMaxBytes = 64 << 10 // a text diff shown per changed file opUpperMaxBytes = 256 << 20 // the tmpfs the op writes into opsFileName = "ops.json" opRunsKeep = 200 opsFamily = "ops" // the caps family every op_* op is judged on opKindRun = "op_run" opKindGrant = "op_grant" opSandboxMount = "/opt/bastion-op" // where the op directory appears inside the sandbox opModeDryRun = "dry_run" opModeApply = "apply" opModeVerify = "verify" opModeUndo = "undo" opsRunsDirName = "runs" opsStoreDirName = "ops" opsMaxHeldOps = 200 opsMaxEffects = 64 opsMaxParams = 32 opsMaxEnumValues = 64 opsMaxStringParam = 4096 ) var ( opNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) opVersionRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) opParamRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`) opsMu sync.Mutex ) // opInterpreters is the fixed set. The bastion execs ` // --mode `; nothing the host wrote chooses the program that runs. var opInterpreters = map[string]string{ "/bin/sh": "/bin/sh", "python3": "/usr/bin/python3", } // ── the manifest ──────────────────────────────────────────────────────────── type opParam struct { Type string `yaml:"type" json:"type"` // string | integer | boolean | enum | path Min *int64 `yaml:"min,omitempty" json:"min,omitempty"` Max *int64 `yaml:"max,omitempty" json:"max,omitempty"` Values []string `yaml:"values,omitempty" json:"values,omitempty"` // enum Root string `yaml:"root,omitempty" json:"root,omitempty"` // path: must resolve under this absolute root MaxLen int `yaml:"max_len,omitempty" json:"max_len,omitempty"` } type opEffects struct { // Files: absolute paths or globs (filepath.Match, so `*` never crosses a // `/`); an entry ending in "/" is a directory and everything under it. Files []string `yaml:"files" json:"files"` // Anything else is refused in rung 6 — named here so a manifest that // carries it is refused with the sentence, not silently ignored. Units []string `yaml:"units,omitempty" json:"units,omitempty"` Packages []string `yaml:"packages,omitempty" json:"packages,omitempty"` Network []string `yaml:"network,omitempty" json:"network,omitempty"` } type opManifest struct { Name string `yaml:"name" json:"name"` Version string `yaml:"version" json:"version"` Summary string `yaml:"summary" json:"summary"` Target string `yaml:"target" json:"target"` // machine Privilege string `yaml:"privilege" json:"privilege"` // user | root Interpreter string `yaml:"interpreter" json:"interpreter"` TimeLimit int `yaml:"time_limit" json:"time_limit"` // seconds Params map[string]opParam `yaml:"params" json:"params"` Effects opEffects `yaml:"effects" json:"effects"` // Reads: paths or globs the op only reads, judged like effects. With the // effects they are all an op sees beyond the system base (ops_view.go). Reads []string `yaml:"reads,omitempty" json:"reads,omitempty"` Modes []string `yaml:"modes" json:"modes"` // must hold dry_run, apply, verify Undo string `yaml:"undo" json:"undo"` // "run" | "none" // Kind: "files" (the default, rung 6) or "action" (ops_action.go). The // fields below belong to an action op and are refused on any other. Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` Destinations []string `yaml:"destinations,omitempty" json:"destinations,omitempty"` // host:port Credentials []string `yaml:"credentials,omitempty" json:"credentials,omitempty"` // names; sealed values ride the apply Builder bool `yaml:"builder,omitempty" json:"builder,omitempty"` // the machine's builder socket, in apply Verbs []map[string]string `yaml:"verbs,omitempty" json:"verbs,omitempty"` } func (m *opManifest) isAction() bool { return m.Kind == opKindAction } func (m *opManifest) hasMode(mode string) bool { for _, x := range m.Modes { if x == mode { return true } } return false } // parseOpManifest reads op.yaml and refuses everything rung 6 does not carry. func parseOpManifest(raw []byte) (*opManifest, error) { var m opManifest if err := yaml.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("op.yaml does not parse: %v", err) } if !opNameRe.MatchString(m.Name) { return nil, errors.New("op.yaml: name must be [a-z][a-z0-9-]{0,63}") } if !opVersionRe.MatchString(m.Version) { return nil, errors.New("op.yaml: version must be MAJOR.MINOR.PATCH") } m.Summary = strings.TrimSpace(m.Summary) if m.Summary == "" || len(m.Summary) > 200 || strings.ContainsAny(m.Summary, "\n\r") { return nil, errors.New("op.yaml: summary is one plain sentence (1-200 chars)") } if m.Target != "machine" { return nil, errors.New("op.yaml: target must be 'machine' (host-target ops are a later rung)") } if m.Privilege != "user" && m.Privilege != "root" { return nil, errors.New("op.yaml: privilege must be 'user' or 'root'") } if _, ok := opInterpreters[m.Interpreter]; !ok { return nil, fmt.Errorf("op.yaml: interpreter must be one of %s", strings.Join(opInterpreterNames(), ", ")) } if m.TimeLimit <= 0 || time.Duration(m.TimeLimit)*time.Second > opTimeLimitMax { return nil, fmt.Errorf("op.yaml: time_limit must be 1..%d seconds", int(opTimeLimitMax.Seconds())) } for _, need := range []string{opModeDryRun, opModeApply, opModeVerify} { if !m.hasMode(need) { return nil, fmt.Errorf("op.yaml: modes must include %s", need) } } if m.Undo != "run" && m.Undo != "none" { return nil, errors.New("op.yaml: undo must be 'run' (the op has an undo mode) or 'none' (stated: cannot be undone)") } if m.Undo == "run" && !m.hasMode(opModeUndo) { return nil, errors.New("op.yaml: undo: run requires the undo mode") } if len(m.Params) > opsMaxParams { return nil, fmt.Errorf("op.yaml: at most %d params", opsMaxParams) } for name, p := range m.Params { if !opParamRe.MatchString(name) { return nil, fmt.Errorf("op.yaml: param %q: name must be [a-z][a-z0-9_]{0,31}", name) } switch p.Type { case "string": if p.MaxLen <= 0 || p.MaxLen > opsMaxStringParam { return nil, fmt.Errorf("op.yaml: param %q: a string needs max_len 1..%d", name, opsMaxStringParam) } case "integer": if p.Min == nil || p.Max == nil || *p.Min > *p.Max { return nil, fmt.Errorf("op.yaml: param %q: an integer needs min ≤ max", name) } case "boolean": case "enum": if len(p.Values) == 0 || len(p.Values) > opsMaxEnumValues { return nil, fmt.Errorf("op.yaml: param %q: an enum needs 1..%d values", name, opsMaxEnumValues) } case "path": if !filepath.IsAbs(p.Root) || filepath.Clean(p.Root) == "/" { return nil, fmt.Errorf("op.yaml: param %q: a path needs an absolute root that is not /", name) } default: return nil, fmt.Errorf("op.yaml: param %q: type must be string|integer|boolean|enum|path", name) } } switch m.Kind { case opKindAction: if err := opActionManifestOK(&m); err != nil { return nil, err } return &m, nil case "", opKindFiles: if err := opFilesOnlyFields(&m); err != nil { return nil, err } default: return nil, errors.New("op.yaml: kind must be 'files' (the default) or 'action'") } if len(m.Effects.Units)+len(m.Effects.Packages)+len(m.Effects.Network) > 0 { return nil, errors.New("op.yaml: a files op changes files only — a unit, a workload or the network is an action op's verb or destination") } if len(m.Effects.Files) == 0 || len(m.Effects.Files) > opsMaxEffects { return nil, fmt.Errorf("op.yaml: effects.files must name 1..%d paths or globs", opsMaxEffects) } for _, f := range m.Effects.Files { if err := effectOK(f); err != nil { return nil, fmt.Errorf("op.yaml: effects.files %q: %v", f, err) } } if len(m.Reads) > opsMaxEffects { return nil, fmt.Errorf("op.yaml: reads may name at most %d paths or globs", opsMaxEffects) } for _, f := range m.Reads { if err := effectOK(f); err != nil { return nil, fmt.Errorf("op.yaml: reads %q: %v", f, err) } } return &m, nil } func opInterpreterNames() []string { out := make([]string, 0, len(opInterpreters)) for k := range opInterpreters { out = append(out, k) } sort.Strings(out) return out } // opOffLimits is where this box keeps its own record — the config dir (the // pinned keys, the pairings, ops.json, the nonce ledger, the sandbox // scratch), and every pairing's DCN dir and backup folder when it was // configured elsewhere. // No op may name them as an effect and no run may change them, whatever // the manifest says: an op that could rewrite the pinned verify key would // make every later signature check the host's own, which is the root of // trust §10b says the host cannot reach. // // Each place is listed as spelled AND as it resolves (a symlinked home, an // OSTree /root): the run's changes are real paths. A record that cannot be // enumerated (the pairings unreadable) refuses every op rather than leave a // DCN dir unprotected. Inside the sandbox each of these is masked with an // empty read-only tmpfs (ops_exec_linux.go), so an op cannot read, write or // remove it whatever this side's text checks miss. func opOffLimits() ([]string, error) { out, _, err := opOffLimitsSplit() return out, err } // opOffLimitsSplit is opOffLimits with the entries that are hosts' backup // folders marked: those can live on a network mount that stops answering, // so they are resolved with a bound here and masked best-effort // (opmask.go), while every text check still refuses naming them. func opOffLimitsSplit() ([]string, map[string]bool, error) { var out []string backups := map[string]bool{} add := func(p string) { if p == "" { return } c := filepath.Clean(p) out = append(out, c) if r := resolveExisting(c); r != c { out = append(out, r) } } addBackup := func(p string) { if p == "" { return } c := filepath.Clean(p) out = append(out, c) backups[c] = true // As it resolves now, or, while it does not answer, as it last // resolved: the real place stays off limits through an outage. if r := opResolveBounded(c); r != "" && r != c { out = append(out, r) backups[r] = true } } d, err := configDirPath() if err != nil || d == "" { return nil, nil, errors.New("this machine's own record cannot be located; ops are refused") } add(d) // The other key material this daemon keeps on disk: the free-agent // stores' host keys and age recipients, the observe kubeconfig and // endpoints under /etc, and the singleton marker under /var/lib. add(faStateBase()) add("/var/lib/privatae/freeagent") add(filepath.Dir(observeKubeconfig)) add(markerDirLinux) cfgs, err := loadPairings() if err != nil { return nil, nil, errors.New("this machine's pairings cannot be read; ops are refused") } for _, c := range cfgs { if c != nil { add(c.DCNDir) // The backups this box holds for each host, wherever the person // put them (a NAS mount): an op that could change them could // replace a backup, or the file thinning is told arrived. addBackup(c.backupRootPath()) } } return out, backups, nil } // resolveExisting resolves the links in a path whose deepest part may not // exist yet (the record before first use): the longest existing ancestor // is resolved and the rest re-appended. func resolveExisting(p string) string { if r, err := filepath.EvalSymlinks(p); err == nil { return filepath.Clean(r) } parent := filepath.Dir(p) if parent == p { return p } return filepath.Join(resolveExisting(parent), filepath.Base(p)) } // underOffLimits names the protected place a path is at or under, or that // the path HOLDS (a folder above the record: removing or replacing it takes // the record with it). "" when neither; an error when the record cannot be // enumerated. func underOffLimits(p string) (string, error) { return offLimitsMatch(p, true) } // offLimitsMatch is underOffLimits with the "holds" half optional. func offLimitsMatch(p string, holds bool) (string, error) { offs, err := opOffLimits() if err != nil { return "", err } clean := filepath.Clean(p) for _, off := range offs { if clean == off || strings.HasPrefix(clean, off+"/") || (holds && strings.HasPrefix(off, clean+"/")) { return off, nil } } return "", nil } // globReachesOffLimits: could this glob name a protected root, anything // inside it, or a folder holding it? Segment by segment against each root // over the shorter of the two — a `*` never crosses a `/`, but a later // segment can (`/etc/*/observe.kubeconfig`), and `/etc/privatae-*` names // the folder itself. `/etc/*.conf` and `/var/lib/*/state` pass: their // segments match no root's. A bracket class holding a `/` is refused // outright (filepath.Match lets it match one). func globReachesOffLimits(glob string) (string, error) { offs, err := opOffLimits() if err != nil { return "", err } if strings.Contains(glob, "[") { for _, part := range strings.Split(glob, "[")[1:] { if i := strings.Index(part, "]"); i < 0 || strings.Contains(part[:i], "/") { return "", errors.New("a bracket class may not hold a /") } } } gsegs := pathSegments(glob) for _, off := range offs { if globCouldName(gsegs, off, true) { return off, nil } } return "", nil } // globCouldName: could a glob (split into segments) name root or something // inside it — and, with holds, a folder holding root? Segment by segment over // the root's own segments, or over the glob's when holds and it is shorter. func globCouldName(gsegs []string, root string, holds bool) bool { rsegs := pathSegments(root) n := len(rsegs) if len(gsegs) < n { if !holds { return false } n = len(gsegs) } for i := 0; i < n; i++ { if ok, err := filepath.Match(gsegs[i], rsegs[i]); err != nil || !ok { return false } } return true } // effectOK: absolute, cleaned, never "/", never a param placeholder, and // not one of the places this box keeps its own record or an op's sandbox // scratch — those cannot be effects, whatever the manifest says. func effectOK(f string) error { if !filepath.IsAbs(f) { return errors.New("must be absolute") } if strings.Contains(f, "{") || strings.Contains(f, "..") { return errors.New("no placeholders, no ..") } if strings.Contains(f, "\\") { // A backslash is filepath.Match's escape: `\n` is a pattern the // literal checks would read as text, and `\/` a `/` no split sees. return errors.New("no backslash") } clean := filepath.Clean(f) if clean == "/" || clean == "." { return errors.New("never /") } // A glob is judged the same way the record is below: "/b*/*" names // /boot/vmlinuz as surely as "/boot/vmlinuz" does. for _, off := range []string{"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp", "/boot"} { if clean == off || strings.HasPrefix(clean, off+"/") || (isGlob(clean) && globCouldName(pathSegments(clean), off, false)) { return errors.New("not under " + off) } } // A glob is judged segment by segment against the box's own record — // could it name the record, anything inside it, or a folder holding it // (globReachesOffLimits); a plain path is judged at, under or above. if isGlob(clean) { off, err := globReachesOffLimits(clean) if err != nil { return err } if off != "" { return errors.New("could name this machine's own record (" + off + ")") } } else { off, err := offLimitsMatch(clean, true) if err != nil { return err } if off != "" { return errors.New("never at, under or above this machine's own record (" + off + ")") } } if _, err := filepath.Match(f, ""); err != nil { return errors.New("bad glob") } // A credential store: refused until the rung that seals a dry run to the // person's device (ops_view.go). Checked again when the op runs, since a // path can come to resolve somewhere else after it was accepted. sens, err := opSensitiveMatch(clean) if err != nil { return err } if sens != "" { return opSensitiveRefusal("it", sens) } return nil } // effectCovers reports whether a changed path is inside the declared effects. // // A glob is matched SEGMENT BY SEGMENT with equal segment counts — the one // definition effectOK's gate (globReachesOffLimits) also uses — so no class // or escape can ever span a `/`: `[^a]` and `[.-0]` match one under // filepath.Match, and a whole-path match would let `/etc/x[^a]y` cover // `/etc/x/y`. func effectCovers(effects []string, changed string) bool { csegs := pathSegments(changed) for _, e := range effects { if strings.HasSuffix(e, "/") { dir := filepath.Clean(e) if changed == dir || strings.HasPrefix(changed, dir+"/") { return true } continue } if isGlob(e) { esegs := pathSegments(e) if len(esegs) != len(csegs) { continue } all := true for i := range esegs { if ok, err := filepath.Match(esegs[i], csegs[i]); err != nil || !ok { all = false break } } if all { return true } continue } if e == changed { return true } } return false } func isGlob(e string) bool { return strings.ContainsAny(e, "*?[") } func pathSegments(p string) []string { return strings.Split(strings.Trim(filepath.Clean(p), "/"), "/") } // ── params: the schema, the values, the bounds ────────────────────────────── // checkParams validates one run's params (the JSON object the pod produced) // against the manifest. Every declared param must be present, nothing // undeclared may be, and every value must be of its type and inside its // bounds. A path param must clean to something under its root. func checkParams(m *opManifest, raw string) (map[string]any, error) { if len(raw) > opParamsMaxBytes { return nil, errors.New("params too large") } var vals map[string]any if err := json.Unmarshal([]byte(raw), &vals); err != nil || vals == nil { return nil, errors.New("params are not a JSON object") } for k := range vals { if _, ok := m.Params[k]; !ok { return nil, fmt.Errorf("param %q is not one the op declares", k) } } for name, p := range m.Params { v, ok := vals[name] if !ok { return nil, fmt.Errorf("param %q missing", name) } switch p.Type { case "string": s, ok := v.(string) if !ok || len(s) > p.MaxLen || strings.ContainsAny(s, "\x00") { return nil, fmt.Errorf("param %q: a string of at most %d bytes", name, p.MaxLen) } case "integer": f, ok := v.(float64) if !ok || f != float64(int64(f)) || int64(f) < *p.Min || int64(f) > *p.Max { return nil, fmt.Errorf("param %q: an integer in %d..%d", name, *p.Min, *p.Max) } case "boolean": if _, ok := v.(bool); !ok { return nil, fmt.Errorf("param %q: true or false", name) } case "enum": s, ok := v.(string) found := false for _, x := range p.Values { if ok && x == s { found = true } } if !found { return nil, fmt.Errorf("param %q: one of %s", name, strings.Join(p.Values, ", ")) } case "path": s, ok := v.(string) if !ok || !filepath.IsAbs(s) { return nil, fmt.Errorf("param %q: an absolute path under %s", name, p.Root) } c := filepath.Clean(s) if c != filepath.Clean(p.Root) && !strings.HasPrefix(c, filepath.Clean(p.Root)+"/") { return nil, fmt.Errorf("param %q: must be under %s", name, p.Root) } } } return vals, nil } // opBounds is what an op_grant lets vary per run: for each param, the // subset of its schema the person approved. Absent = the schema's own bound. type opBound struct { Min *int64 `json:"min,omitempty"` Max *int64 `json:"max,omitempty"` Values []string `json:"values,omitempty"` // enum or string: one of these Prefix string `json:"prefix,omitempty"` // path: must be under this (itself under the schema root) Fixed *bool `json:"fixed,omitempty"` // boolean } // Strict: a field the param's type does not read is refused, not ignored — // the card shows the bounds text as sent, so a bound the box would not // enforce would read as a limit the person never got. Each bound must sit // inside the schema's own. func parseBounds(m *opManifest, raw string) (map[string]opBound, error) { if len(raw) > opParamsMaxBytes { return nil, errors.New("bounds too large") } dec := json.NewDecoder(strings.NewReader(raw)) dec.DisallowUnknownFields() var b map[string]opBound if err := dec.Decode(&b); err != nil || b == nil { return nil, errors.New("bounds are not a JSON object of {param: {min, max | values | prefix | fixed}}") } for k, bd := range b { p, ok := m.Params[k] if !ok { return nil, fmt.Errorf("bound %q names no param", k) } has := func(min, max, values, prefix, fixed bool) error { if (bd.Min != nil || bd.Max != nil) && !min { return fmt.Errorf("bound %q: min/max is not a bound for a %s", k, p.Type) } if bd.Values != nil && !values { return fmt.Errorf("bound %q: values is not a bound for a %s", k, p.Type) } if bd.Prefix != "" && !prefix { return fmt.Errorf("bound %q: prefix is not a bound for a %s", k, p.Type) } if bd.Fixed != nil && !fixed { return fmt.Errorf("bound %q: fixed is not a bound for a %s", k, p.Type) } return nil } switch p.Type { case "integer": if err := has(true, true, false, false, false); err != nil { return nil, err } if bd.Min == nil && bd.Max == nil { return nil, fmt.Errorf("bound %q: an integer bound is min and/or max", k) } if (bd.Min != nil && *bd.Min < *p.Min) || (bd.Max != nil && *bd.Max > *p.Max) || (bd.Min != nil && bd.Max != nil && *bd.Min > *bd.Max) { return nil, fmt.Errorf("bound %q: must sit inside the op's own %d..%d", k, *p.Min, *p.Max) } case "enum", "string": if err := has(false, false, true, false, false); err != nil { return nil, err } if len(bd.Values) == 0 { return nil, fmt.Errorf("bound %q: a %s bound is a non-empty values list", k, p.Type) } for _, v := range bd.Values { if p.Type == "enum" { found := false for _, x := range p.Values { if x == v { found = true } } if !found { return nil, fmt.Errorf("bound %q: %q is not one of the op's values", k, v) } } else if len(v) > p.MaxLen { return nil, fmt.Errorf("bound %q: %q is longer than the op's %d bytes", k, v, p.MaxLen) } } case "path": if err := has(false, false, false, true, false); err != nil { return nil, err } c := filepath.Clean(bd.Prefix) if bd.Prefix == "" || !filepath.IsAbs(bd.Prefix) || (c != filepath.Clean(p.Root) && !strings.HasPrefix(c, filepath.Clean(p.Root)+"/")) { return nil, fmt.Errorf("bound %q: prefix must be an absolute path under the op's root %s", k, p.Root) } case "boolean": if err := has(false, false, false, false, true); err != nil { return nil, err } if bd.Fixed == nil { return nil, fmt.Errorf("bound %q: a boolean bound is fixed: true|false", k) } } } return b, nil } // checkBounds: the params, already valid for the schema, sit inside the grant's bounds. func checkBounds(m *opManifest, bounds map[string]opBound, vals map[string]any) error { for name, b := range bounds { v := vals[name] switch m.Params[name].Type { case "integer": f, _ := v.(float64) if (b.Min != nil && int64(f) < *b.Min) || (b.Max != nil && int64(f) > *b.Max) { return fmt.Errorf("param %q is outside the approved range", name) } case "enum", "string": if len(b.Values) > 0 { s, _ := v.(string) ok := false for _, x := range b.Values { if x == s { ok = true } } if !ok { return fmt.Errorf("param %q is not one of the approved values", name) } } case "path": if b.Prefix != "" { s, _ := v.(string) c := filepath.Clean(s) p := filepath.Clean(b.Prefix) if c != p && !strings.HasPrefix(c, p+"/") { return fmt.Errorf("param %q is outside the approved folder", name) } } case "boolean": if b.Fixed != nil { x, _ := v.(bool) if x != *b.Fixed { return fmt.Errorf("param %q is fixed to %v in the approval", name, *b.Fixed) } } } } return nil } // ── the op directory: bundle, digest, store ───────────────────────────────── type opFile struct { Path string `json:"path"` // relative, forward slashes Mode uint32 `json:"mode"` // permission bits only B64 string `json:"b64"` } // opDirDigest is the op's identity: sha256 over the files sorted by path, // each framed "\x00\x00\x00" then the bytes. func opDirDigest(files []opFile) (string, error) { sorted := append([]opFile(nil), files...) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path }) h := sha256.New() seen := map[string]bool{} for _, f := range sorted { if seen[f.Path] { return "", fmt.Errorf("duplicate path %q", f.Path) } seen[f.Path] = true raw, err := base64.StdEncoding.DecodeString(f.B64) if err != nil { return "", fmt.Errorf("file %q: bad base64", f.Path) } fmt.Fprintf(h, "%s\x00%04o\x00%d\x00", f.Path, f.Mode&0o777, len(raw)) h.Write(raw) } return hex.EncodeToString(h.Sum(nil)), nil } // opBundleOK checks the bundle's shape before anything is written: relative // clean paths, no links, op.yaml and run present, bounded size. func opBundleOK(files []opFile) error { total := 0 have := map[string]bool{} for _, f := range files { p := f.Path if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\\") || strings.Contains(p, "\x00") || path.Clean(p) != p || p == "." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") { return fmt.Errorf("bad path %q in the op bundle", f.Path) } raw, err := base64.StdEncoding.DecodeString(f.B64) if err != nil { return fmt.Errorf("file %q: bad base64", f.Path) } // The two modes an op directory has (opLoad re-hashes with exactly // these): a bundle claiming any other would never load once held. if want := uint32(0o644); (p == "run" && f.Mode&0o777 != 0o755) || (p != "run" && f.Mode&0o777 != want) { return fmt.Errorf("file %q: mode must be 0644 (0755 for run)", f.Path) } total += len(raw) if total > opBundleMaxBytes { return fmt.Errorf("op bundle exceeds %d bytes", opBundleMaxBytes) } have[p] = true } if !have["op.yaml"] || !have["run"] { return errors.New("an op bundle needs op.yaml and run") } return nil } func opsDir(dcnDir string) string { return filepath.Join(dcnDir, opsStoreDirName) } func opsRunsDir(dcnDir string) string { return filepath.Join(dcnDir, opsStoreDirName, opsRunsDirName) } func opsFilePath(dcnDir string) string { return filepath.Join(dcnDir, opsFileName) } func opStoreDir(dcnDir, sha string) string { return filepath.Join(opsDir(dcnDir), sha) } // opInstall writes the bundle under /ops// (0700, files // 0600 or the bundle's mode masked to 0755 for run) once the digest matched. func opInstall(dcnDir, sha string, files []opFile) error { dir := opStoreDir(dcnDir, sha) if _, err := os.Stat(filepath.Join(dir, "op.yaml")); err == nil { return nil // already held: identical by construction (same sha) } tmp := dir + ".tmp" _ = os.RemoveAll(tmp) // World-readable: a user-privilege op runs as the session user and reads // its own files through the read-only bind; the op is signed content, // not a secret. if err := os.MkdirAll(tmp, 0o755); err != nil { return err } for _, f := range files { raw, _ := base64.StdEncoding.DecodeString(f.B64) abs := filepath.Join(tmp, filepath.FromSlash(f.Path)) if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { return err } mode := os.FileMode(0o644) if f.Path == "run" { mode = 0o755 } if err := os.WriteFile(abs, raw, mode); err != nil { return err } } return os.Rename(tmp, dir) } // opLoad reads a held op's manifest, re-hashing the directory so a store // edited on disk is refused rather than run. func opLoad(dcnDir, sha string) (*opManifest, error) { dir := opStoreDir(dcnDir, sha) var files []opFile err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } if !info.Mode().IsRegular() { return fmt.Errorf("op store holds a non-regular file %q", p) } raw, err := os.ReadFile(p) if err != nil { return err } rel, _ := filepath.Rel(dir, p) mode := uint32(0o644) if filepath.ToSlash(rel) == "run" { mode = 0o755 } files = append(files, opFile{Path: filepath.ToSlash(rel), Mode: mode, B64: base64.StdEncoding.EncodeToString(raw)}) return nil }) if err != nil { return nil, fmt.Errorf("op %s is not held here: %v", sha[:12], err) } got, err := opDirDigest(files) if err != nil || got != sha { return nil, errors.New("op store does not hash to the sha256 the terms name — refused") } raw, err := os.ReadFile(filepath.Join(dir, "op.yaml")) if err != nil { return nil, err } return parseOpManifest(raw) } // ── the terms ─────────────────────────────────────────────────────────────── // opTerms is the signed record for either kind. Params and Bounds are the // JSON TEXT the pod produced, carried verbatim and hashed verbatim (never // re-canonicalized here), and handed to the op's stdin as they are. type opTerms struct { Kind string `json:"kind"` Entity int64 `json:"entity"` Op string `json:"op"` Version string `json:"version"` SHA256 string `json:"sha256"` Server string `json:"server"` Privilege string `json:"privilege"` Params string `json:"params,omitempty"` // op_run Bounds string `json:"bounds,omitempty"` // op_grant // Changes: op_run, the apply only — the changes_sha256 the box's own dry // run reported (opReport). What the person approved is that list; the // apply must reproduce it exactly or nothing lands. Changes string `json:"changes_sha256,omitempty"` Expires string `json:"expires"` Uses int `json:"uses"` Nonce string `json:"nonce"` Approval string `json:"approval,omitempty"` } // purposeForOpRun is the apply's purpose: the machine, the op's bytes, the // exact params, the expiry, and the sha256 of the change list the dry run // showed — so the approval is for those changes and no others. func purposeForOpRun(t *opTerms) (string, error) { if err := purposeBad([]string{t.Server, t.Op, t.SHA256, t.Expires, t.Changes}, nil); err != nil { return "", err } if len(t.Changes) != 64 { return "", errors.New("an op_run apply binds the dry run it showed: the terms carry no changes_sha256") } if _, err := hex.DecodeString(t.Changes); err != nil { return "", errors.New("the terms carry a malformed changes_sha256") } sum := sha256.Sum256([]byte(t.Params)) return opKindRun + "|" + t.Server + "|" + t.Op + "|" + t.SHA256 + "|" + hex.EncodeToString(sum[:]) + "|" + t.Expires + "|" + t.Changes, nil } func purposeForOpGrant(t *opTerms) (string, error) { if err := purposeBad([]string{t.Server, t.Op, t.SHA256, t.Expires}, nil); err != nil { return "", err } sum := sha256.Sum256([]byte(t.Bounds)) return opKindGrant + "|" + t.Server + "|" + t.Op + "|" + t.SHA256 + "|" + hex.EncodeToString(sum[:]) + "|" + t.Expires + "|" + strconv.Itoa(t.Uses), nil } // verifyOpTerms: signed under the pinned key, the kind asked for, this // machine, an expiry inside the kind's ceiling, uses as the kind requires, // a nonce, and a well-formed sha256. func verifyOpTerms(dcnDir, termsB64, sigB64, wantKind string) (*opTerms, error) { f, err := dcnLoadFile(dcnDir) if err != nil { return nil, errors.New("grant store unreadable") } if f.VerifyPubkey == "" { return nil, errors.New("no entity key pinned on this box yet (default-deny)") } pub, err := base64.StdEncoding.DecodeString(f.VerifyPubkey) if err != nil || len(pub) != ed25519.PublicKeySize { return nil, errors.New("bad pinned verify key") } canonical, err := base64.StdEncoding.DecodeString(termsB64) if err != nil { return nil, errors.New("bad terms encoding") } sig, err := base64.StdEncoding.DecodeString(sigB64) if err != nil { return nil, errors.New("bad signature encoding") } if !ed25519.Verify(ed25519.PublicKey(pub), canonical, sig) { return nil, errors.New("terms signature does not verify on this box") } var t opTerms if err := json.Unmarshal(canonical, &t); err != nil { return nil, errors.New("signed terms unparsable") } if t.Kind != wantKind { return nil, fmt.Errorf("terms are %q, not %q", t.Kind, wantKind) } if hn, _ := os.Hostname(); hn != "" && t.Server != hn { return nil, fmt.Errorf("terms are for server '%s', this box is '%s'", t.Server, hn) } if !opNameRe.MatchString(t.Op) || len(t.SHA256) != 64 { return nil, errors.New("terms name no op") } if _, err := hex.DecodeString(t.SHA256); err != nil { return nil, errors.New("terms carry a malformed sha256") } if len(t.Nonce) < 16 { return nil, errors.New("terms carry no nonce") } exp, err := time.Parse(time.RFC3339, t.Expires) if err != nil { return nil, errors.New("terms state no expiry (open-ended terms are refused)") } if !exp.After(time.Now()) { return nil, errors.New("terms expired") } switch wantKind { case opKindRun: if t.Uses != 1 { return nil, errors.New("an op_run is one use") } if exp.After(time.Now().Add(opRunTTLMax + deviceClockSkew)) { return nil, fmt.Errorf("an op_run may not live longer than %s", opRunTTLMax) } if t.Params == "" { return nil, errors.New("an op_run carries its params") } case opKindGrant: if t.Uses < 1 || t.Uses > opGrantUsesMax { return nil, fmt.Errorf("an op_grant states uses 1..%d", opGrantUsesMax) } if exp.After(time.Now().Add(opGrantTTLMax + deviceClockSkew)) { return nil, fmt.Errorf("an op_grant may not live longer than %s", opGrantTTLMax) } if t.Privilege != "user" { return nil, errors.New("root is never reusable: an op_grant is privilege user only") } if t.Bounds == "" { return nil, errors.New("an op_grant carries its bounds") } } return &t, nil } // ── ops.json: the held grants ─────────────────────────────────────────────── type opGrantRec struct { TermsCanonical string `json:"terms_canonical"` Signature string `json:"signature"` InstalledAt string `json:"installed_at"` UsesLeft int `json:"uses_left"` Dropped string `json:"dropped,omitempty"` // when and why, kept for the record } type opsFile struct { Grants map[string]opGrantRec `json:"grants"` // by the terms' nonce } func opsLoad(dcnDir string) *opsFile { f := &opsFile{Grants: map[string]opGrantRec{}} raw, err := os.ReadFile(opsFilePath(dcnDir)) if err == nil { _ = json.Unmarshal(raw, f) } if f.Grants == nil { f.Grants = map[string]opGrantRec{} } return f } func opsSave(dcnDir string, f *opsFile) error { raw, err := json.MarshalIndent(f, "", " ") if err != nil { return err } return atomicWrite(opsFilePath(dcnDir), raw) } // opsHeldGrant re-verifies a held grant record (a tampered ops.json holds // nothing) and returns its terms and uses left. func opsHeldGrant(dcnDir, nonce string) (*opTerms, *opGrantRec, error) { f := opsLoad(dcnDir) rec, ok := f.Grants[nonce] if !ok || rec.Dropped != "" { return nil, nil, errors.New("no such op grant is held here") } t, err := verifyOpTerms(dcnDir, rec.TermsCanonical, rec.Signature, opKindGrant) if err != nil { return nil, nil, fmt.Errorf("held op grant does not verify: %v", err) } if rec.UsesLeft <= 0 { return nil, nil, errors.New("op grant has no uses left") } return t, &rec, nil } // opsDropGrant — the console (or a host revoke) removes a held op grant. func opsDropGrant(dcnDir, nonce, why string) (bool, error) { opsMu.Lock() defer opsMu.Unlock() f := opsLoad(dcnDir) rec, ok := f.Grants[nonce] if !ok || rec.Dropped != "" { return false, nil } rec.Dropped = time.Now().UTC().Format(time.RFC3339) + " " + why rec.UsesLeft = 0 f.Grants[nonce] = rec return true, opsSave(dcnDir, f) } // ── the run record ────────────────────────────────────────────────────────── // opAuditRun is the durable line per run: what ran, in which mode, and how // it ended (the record under ops/runs/ is the detail and is pruned). func opAuditRun(dir, op string, r *opRunRecord) { if r == nil { return } decision := "ok run=" + r.ID + " mode=" + r.Mode if r.Approval != "" { decision += " via " + r.Approval } if r.Error != "" { decision = "refused run=" + r.ID + " mode=" + r.Mode + ": " + truncate(r.Error, mgmtAuditWhyMax) } else if r.Report != nil { decision += fmt.Sprintf(" changes=%d", len(r.Report.Changes)) } dcnAudit(dir, op, 0, r.Op+"@"+r.SHA256[:12], decision) } type opRunRecord struct { ID string `json:"id"` At string `json:"at"` Kind string `json:"kind"` // op_run | op_grant Op string `json:"op"` SHA256 string `json:"sha256"` Mode string `json:"mode"` Params string `json:"params"` Nonce string `json:"nonce"` Approval string `json:"approval,omitempty"` // device: | console: Report *opReport `json:"report,omitempty"` Error string `json:"error,omitempty"` Verify *opReport `json:"verify,omitempty"` Commit []string `json:"commit,omitempty"` Terms json.RawMessage `json:"terms,omitempty"` } func opRecordRun(dcnDir string, r *opRunRecord) { dir := opsRunsDir(dcnDir) if err := os.MkdirAll(dir, 0o700); err != nil { return } raw, _ := json.MarshalIndent(r, "", " ") _ = atomicWrite(filepath.Join(dir, r.ID+".json"), raw) // Keep the newest opRunsKeep records; the audit line is the durable one. entries, err := os.ReadDir(dir) if err != nil || len(entries) <= opRunsKeep { return } names := make([]string, 0, len(entries)) for _, e := range entries { names = append(names, e.Name()) } sort.Strings(names) for _, n := range names[:len(names)-opRunsKeep] { _ = os.Remove(filepath.Join(dir, n)) } } // ── the mgmt ops ──────────────────────────────────────────────────────────── // opFail is refuseMgmt: the refusal goes on the box's ring and, for the op // writes, counts toward the host's quarantine (a run that reached outside // its effects is the host asking for what it could have known better than // to ask for). A box that cannot sandbox at all is the box's own state, and // never a strike (refusalCounts). func opFail(ws *websocket.Conn, why string) { refuseMgmt(ws, why) } const noSandboxHere = "this machine cannot sandbox an op" // op_sync : hold the op the terms name, // once the bundle hashes to it and its manifest parses. For op_grant terms // the grant is installed too (its approval consumed, like a store grant). // For op_run terms nothing but the op is held: the run is asked for next. func handleOpSync(ws *websocket.Conn, dir string, args []string) { if dir == "" || len(args) < 3 { opFail(ws, "op_sync: need ") return } var t *opTerms var err error for _, kind := range []string{opKindRun, opKindGrant} { if t, err = verifyOpTerms(dir, args[0], args[1], kind); err == nil { break } } if err != nil { opFail(ws, "op_sync refused: "+err.Error()) return } raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[2])) if err != nil || len(raw) > opBundleMaxBytes*2 { opFail(ws, "op_sync: bad or oversize bundle") return } var files []opFile if err := json.Unmarshal(raw, &files); err != nil { opFail(ws, "op_sync: bundle is not a file list") return } if err := opBundleOK(files); err != nil { opFail(ws, "op_sync refused: "+err.Error()) return } sha, err := opDirDigest(files) if err != nil { opFail(ws, "op_sync refused: "+err.Error()) return } if sha != t.SHA256 { opFail(ws, "op_sync refused: the bundle does not hash to the sha256 the terms name") return } var manifestRaw []byte for _, f := range files { if f.Path == "op.yaml" { manifestRaw, _ = base64.StdEncoding.DecodeString(f.B64) } } m, err := parseOpManifest(manifestRaw) if err != nil { opFail(ws, "op_sync refused: "+err.Error()) return } if m.Name != t.Op || m.Version != t.Version || m.Privilege != t.Privilege { opFail(ws, "op_sync refused: the manifest and the terms disagree on name, version or privilege") return } if entries, err := os.ReadDir(opsDir(dir)); err == nil && len(entries) > opsMaxHeldOps { opFail(ws, fmt.Sprintf("op_sync refused: this box holds %d ops already; drop some", opsMaxHeldOps)) return } opsMu.Lock() err = opInstall(dir, sha, files) opsMu.Unlock() if err != nil { opFail(ws, "op_sync: could not hold the op: "+err.Error()) return } out := map[string]any{"state": "held", "op": t.Op, "sha256": sha, "kind": t.Kind} if t.Kind == opKindGrant { if _, err := parseBounds(m, t.Bounds); err != nil { opFail(ws, "op_sync refused: "+err.Error()) return } purpose, err := purposeForOpGrant(t) if err != nil { opFail(ws, "op_sync refused: "+err.Error()) return } opsMu.Lock() f := opsLoad(dir) if prev, held := f.Grants[t.Nonce]; !held || prev.Dropped != "" { // THE PERSON'S APPROVAL, spent here: one approval installs the // grant once; a dropped grant cannot be re-installed under it. id, err := consumeApproval(dir, t.Approval, purpose) if err != nil { opsMu.Unlock() opFail(ws, "op_grant refused: "+err.Error()) return } f.Grants[t.Nonce] = opGrantRec{TermsCanonical: args[0], Signature: args[1], InstalledAt: time.Now().UTC().Format(time.RFC3339), UsesLeft: t.Uses} if err := opsSave(dir, f); err != nil { opsMu.Unlock() opFail(ws, "op_grant: could not hold the grant: "+err.Error()) return } setMgmtAccountApproval(ws, id) dcnAudit(dir, "op_grant", 0, t.Op, "installed "+t.Nonce[:8]+" uses="+strconv.Itoa(t.Uses)+" via "+id) } opsMu.Unlock() out["grant"] = t.Nonce out["uses"] = t.Uses } b, _ := json.Marshal(out) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // op_dry_run : run the op_run in the sandbox and report // what it WOULD change. Nothing is committed and no approval is needed — // the terms are signed by the host's key, the params are bound in them, and // the sandbox is what makes the run harmless. The person approves the apply // from this report. func handleOpDryRun(ws *websocket.Conn, dir string, args []string) { if dir == "" || len(args) < 2 { opFail(ws, "op_dry_run: need ") return } t, err := verifyOpTerms(dir, args[0], args[1], opKindRun) if err != nil { opFail(ws, "op_dry_run refused: "+err.Error()) return } rep, rec, err := opRunTerms(dir, t, opModeDryRun, "", args[0], args[2:]) opRecordRun(dir, rec) opAuditRun(dir, "op_dry_run", rec) if err != nil { opFail(ws, "op_dry_run refused: "+err.Error()) return } b, _ := json.Marshal(rep) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // op_apply : spend the person's approval, run the same // sandbox run again, check it again, commit it, verify it, report. func handleOpApply(ws *websocket.Conn, dir string, args []string) { if dir == "" || len(args) < 2 { opFail(ws, "op_apply: need ") return } t, err := verifyOpTerms(dir, args[0], args[1], opKindRun) if err != nil { opFail(ws, "op_apply refused: "+err.Error()) return } purpose, err := purposeForOpRun(t) if err != nil { opFail(ws, "op_apply refused: "+err.Error()) return } // The terms' nonce is its own single-use wall on top of the approval's: // the same signed op_run cannot be applied twice even if the approval // were somehow reissued. if opNonceSeen(dir, t.Nonce) { opFail(ws, "op_apply refused: this op_run was applied already") return } // An action op's sealed credentials (args after the signature) are // judged before the approval is spent: a run missing one, or handed one // sealed for another run, is refused with the approval still unused. creds := args[2:] if m, err := opLoad(dir, t.SHA256); err == nil && m.isAction() { if _, err := opOpenCreds(creds, m, t.Nonce, false); err != nil { opFail(ws, "op_apply refused: "+err.Error()) return } } else if len(creds) > 0 { opFail(ws, "op_apply refused: only an action op is handed credentials") return } approvalID, err := boxConsume(dir, t.Approval, purpose) if err != nil { opFail(ws, "op_apply refused: "+err.Error()) return } setMgmtAccountApproval(ws, approvalID) opBurnNonce(dir, t.Nonce, t.Expires) rep, rec, err := opRunTerms(dir, t, opModeApply, approvalID, args[0], creds) opRecordRun(dir, rec) opAuditRun(dir, "op_apply", rec) if err != nil { opFail(ws, "op_apply refused: "+err.Error()) return } b, _ := json.Marshal(rep) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // op_grant_run : a run under a held grant — // dry_run or apply — with the params checked against the schema AND the // approved bounds; an apply spends one use. func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) { if dir == "" || len(args) < 3 { opFail(ws, "op_grant_run: need ") return } mode := strings.TrimSpace(args[1]) if mode != opModeDryRun && mode != opModeApply { opFail(ws, "op_grant_run: mode must be dry_run or apply") return } opsMu.Lock() t, rec, err := opsHeldGrant(dir, strings.TrimSpace(args[0])) opsMu.Unlock() if err != nil { opFail(ws, "op_grant_run refused: "+err.Error()) return } run := *t run.Kind = opKindRun run.Params = args[2] run.Uses = 1 m, err := opLoad(dir, t.SHA256) if err != nil { opFail(ws, "op_grant_run refused: "+err.Error()) return } vals, err := checkParams(m, run.Params) if err != nil { opFail(ws, "op_grant_run refused: "+err.Error()) return } bounds, err := parseBounds(m, t.Bounds) if err != nil { opFail(ws, "op_grant_run refused: "+err.Error()) return } if err := checkBounds(m, bounds, vals); err != nil { opFail(ws, "op_grant_run refused: "+err.Error()) return } creds := args[3:] if m.isAction() { if _, err := opOpenCreds(creds, m, t.Nonce, mode == opModeDryRun); err != nil { opFail(ws, "op_grant_run refused: "+err.Error()) return } } else if len(creds) > 0 { opFail(ws, "op_grant_run refused: only an action op is handed credentials") return } if mode == opModeApply { // Spend the use BEFORE the run (the same order the approval takes). opsMu.Lock() f := opsLoad(dir) r := f.Grants[t.Nonce] if r.UsesLeft <= 0 || r.Dropped != "" { opsMu.Unlock() opFail(ws, "op_grant_run refused: op grant has no uses left") return } r.UsesLeft-- f.Grants[t.Nonce] = r if err := opsSave(dir, f); err != nil { opsMu.Unlock() opFail(ws, "op_grant_run refused: could not record the use; refused rather than unlimited") return } opsMu.Unlock() _ = rec } rep, record, err := opRunTerms(dir, &run, mode, "grant:"+t.Nonce, "", creds) record.Kind = opKindGrant opRecordRun(dir, record) opAuditRun(dir, "op_grant_run", record) if err != nil { opFail(ws, "op_grant_run refused: "+err.Error()) return } b, _ := json.Marshal(rep) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // op_list: the ops held, the grants held, and whether this box can sandbox. func handleOpList(ws *websocket.Conn, dir string, _ []string) { if dir == "" { opFail(ws, "op_list: dcn store not configured") return } held := []map[string]any{} if entries, err := os.ReadDir(opsDir(dir)); err == nil { for _, e := range entries { if !e.IsDir() || e.Name() == opsRunsDirName || strings.HasSuffix(e.Name(), ".tmp") { continue } item := map[string]any{"sha256": e.Name()} if m, err := opLoad(dir, e.Name()); err == nil { item["op"], item["version"], item["summary"], item["privilege"] = m.Name, m.Version, m.Summary, m.Privilege item["kind"] = opKindFiles if m.isAction() { item["kind"] = opKindAction } } else { item["error"] = err.Error() } held = append(held, item) } } grants := []map[string]any{} opsMu.Lock() f := opsLoad(dir) opsMu.Unlock() for nonce, rec := range f.Grants { g := map[string]any{"grant": nonce, "uses_left": rec.UsesLeft, "installed_at": rec.InstalledAt, "dropped": rec.Dropped} if t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant); err == nil { g["op"], g["sha256"], g["expires"], g["bounds"] = t.Op, t.SHA256, t.Expires, t.Bounds } else { g["error"] = err.Error() } grants = append(grants, g) } ok, why := opSandboxAvailable() b, _ := json.Marshal(map[string]any{"ops": held, "grants": grants, "sandbox": ok, "sandbox_reason": why}) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // op_run_status : what became of an op_run whose answer the host // lost. A read. The host burns nothing by asking, and it needs the answer // because the box burns the run's nonce BEFORE the op runs (one attempt per // approval): "applied already" on a re-send means the run was attempted, // not that it landed. The states: // // found=true, applied=true the commit finished (verify may still have failed: verify_error) // found=true, applied=false refused, or the commit stopped part-way (committed names what landed) // found=false, nonce_burned=false the job never arrived; the same run may be sent again // found=false, nonce_burned=true attempted — still running, or the record pruned or never // written: unknown; never re-send // // Only op_run records answer here: a run under a grant carries the grant's // nonce, which is never burned. func handleOpRunStatus(ws *websocket.Conn, dir string, args []string) { if dir == "" || len(args) < 1 || strings.TrimSpace(args[0]) == "" { opFail(ws, "op_run_status: need ") return } nonce := strings.TrimSpace(args[0]) out := map[string]any{"nonce": nonce, "found": false, "nonce_burned": opNonceSeen(dir, nonce)} var best *opRunRecord if entries, err := os.ReadDir(opsRunsDir(dir)); err == nil { for _, e := range entries { raw, err := os.ReadFile(filepath.Join(opsRunsDir(dir), e.Name())) if err != nil { continue } var r opRunRecord if json.Unmarshal(raw, &r) != nil || r.Nonce != nonce || r.Mode != opModeApply || r.Kind != opKindRun { continue } if best == nil || r.At > best.At { rr := r best = &rr } } } if best != nil { out["found"] = true out["id"], out["at"], out["mode"], out["error"] = best.ID, best.At, best.Mode, best.Error out["committed"] = best.Commit // Applied = the commit finished. A verify that failed to start is // recorded as "verify: …" AFTER the commit and reported to the host // as a success with verify_error; here it reads the same way. out["applied"] = best.Error == "" || strings.HasPrefix(best.Error, "verify: ") if best.Report != nil { out["changes_sha256"] = best.Report.ChangesSHA256 out["verify_error"] = best.Report.VerifyError out["exit"] = best.Report.Exit } } b, _ := json.Marshal(out) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // ── the run nonce ledger (an op_run applies once) ─────────────────────────── func opNoncesPath(dir string) string { return filepath.Join(dir, "op_nonces.json") } func opNonceSeen(dir, nonce string) bool { opsMu.Lock() defer opsMu.Unlock() _, ok := opNoncesLoad(dir)[nonce] return ok } func opNoncesLoad(dir string) map[string]string { out := map[string]string{} if raw, err := os.ReadFile(opNoncesPath(dir)); err == nil { _ = json.Unmarshal(raw, &out) } now := time.Now() for n, exp := range out { if t, err := time.Parse(time.RFC3339, exp); err != nil || !t.After(now) { delete(out, n) } } return out } func opBurnNonce(dir, nonce, expires string) { opsMu.Lock() defer opsMu.Unlock() m := opNoncesLoad(dir) m[nonce] = expires raw, _ := json.MarshalIndent(m, "", " ") _ = atomicWrite(opNoncesPath(dir), raw) } // ── running the terms ─────────────────────────────────────────────────────── // opRunTerms loads the op the terms name, checks the params, runs the mode, // and returns the report and the run record. `apply` commits and verifies. func opRunTerms(dir string, t *opTerms, mode, approvalID, termsB64 string, creds []string) (*opReport, *opRunRecord, error) { id, _ := newID() rec := &opRunRecord{ID: id, At: time.Now().UTC().Format(time.RFC3339), Kind: t.Kind, Op: t.Op, SHA256: t.SHA256, Mode: mode, Params: t.Params, Nonce: t.Nonce, Approval: approvalID} if termsB64 != "" { if raw, err := base64.StdEncoding.DecodeString(termsB64); err == nil { rec.Terms = raw } } m, err := opLoad(dir, t.SHA256) if err != nil { rec.Error = err.Error() return nil, rec, err } if m.Name != t.Op || m.Version != t.Version || m.Privilege != t.Privilege { rec.Error = "the held op and the terms disagree on name, version or privilege" return nil, rec, errors.New(rec.Error) } if _, err := checkParams(m, t.Params); err != nil { rec.Error = err.Error() return nil, rec, err } if ok, why := opSandboxAvailable(); !ok { rec.Error = noSandboxHere + ": " + why return nil, rec, errors.New(rec.Error) } if m.isAction() { // An action op (ops_action.go): its program in a transient unit, its // verbs by the bastion, its change list bound like a files op's. rep, err := opRunAction(opStoreDir(dir, t.SHA256), m, t, mode, id, creds, nil) rec.Report = rep if rep != nil { rec.Commit = rep.Committed } if err != nil { rec.Error = err.Error() return rep, rec, err } return rep, rec, nil } if len(creds) > 0 { rec.Error = "only an action op is handed credentials" return nil, rec, errors.New(rec.Error) } rep, err := opRunSandboxed(opStoreDir(dir, t.SHA256), m, mode, t.Params) if rep != nil { defer rep.cleanup() } if err != nil { rec.Error = err.Error() return rep, rec, err } rec.Report = rep if why := opEffectsViolation(m, rep); why != "" { rec.Error = why return rep, rec, errors.New(why) } if mode != opModeApply { return rep, rec, nil } // APPLY IS A COMMITTED DRY RUN: the same run, checked again, then the // upper dir is what lands — file by file, the diff the person saw. For an // op_run the person approved a change list by its sha256; a run that // produced any other list (a nondeterministic op, a machine that moved // under it) lands nothing. // (A run under a grant carries no list: its bounds are what was approved.) if t.Changes != "" && rep.ChangesSHA256 != t.Changes { rec.Error = "refused before commit: the apply would change something other than what the dry run showed (" + truncate(rep.ChangesSHA256, 12) + " vs approved " + truncate(t.Changes, 12) + ") — dry-run again and approve that" return rep, rec, errors.New(rec.Error) } committed, err := opCommitUpper(rep) rec.Commit = committed if err != nil { rec.Error = "commit: " + err.Error() return rep, rec, fmt.Errorf("commit: %v (%d change(s) landed before it)", err, len(committed)) } ver, verr := opRunSandboxed(opStoreDir(dir, t.SHA256), m, opModeVerify, t.Params) if ver != nil { defer ver.cleanup() } rec.Verify = ver if verr != nil { rec.Error = "verify: " + verr.Error() rep.VerifyError = verr.Error() } else if ver != nil && ver.Exit != 0 { rep.VerifyError = fmt.Sprintf("verify exited %d", ver.Exit) } rep.Committed = committed return rep, rec, nil } // opEffectsViolation names the first change outside the declared effects — // and, whatever the effects say, any change to this machine's own record // (opOffLimits). func opEffectsViolation(m *opManifest, rep *opReport) string { for _, c := range rep.Changes { if c.Kind == "symlink" || c.Kind == "hardlink" || c.Kind == "opaque" || c.Kind == "special" { return "refused: the run left a " + c.Kind + " at " + c.Path + " — a link or a replaced directory is a path outside the effects in disguise" } off, err := underOffLimits(c.Path) if err != nil { return "refused: " + err.Error() } if off != "" { return "refused: the run changed " + c.Path + ", which is or holds this machine's own record (" + off + ") — never an effect" } if !effectCovers(m.Effects.Files, c.Path) { return "refused: the run changed " + c.Path + ", which is outside the op's declared effects" } if c.Kind == "removed_dir" || c.Kind == "opaque" { // The sandbox never looked inside a mount below this folder (an // overlay does not show them, and not every one is carried), so // removing it on the machine would reach what the run never saw. if under := opMountsUnder(c.Path); len(under) > 0 { return "refused: the run removed " + c.Path + ", which holds a mount at " + under[0] + " the sandbox did not carry" } } } return "" }