Files
privatae-bastion/client-go/ops.go
T
Privatae 8822d5df85
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
bastion 0.55.3
2026-10-25 23:16:45 +00:00

1708 lines
59 KiB
Go

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 <dcnDir>/ops/<sha256>/.
//
// 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 `<interpreter> <run>
// --mode <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 "<path>\x00<mode octal>\x00<size>\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 <dcnDir>/ops/<sha256>/ (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:<nonce> | console:<id>
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 <terms_b64> <sig_b64> <bundle_b64>: 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 <terms_b64> <sig_b64> <bundle_b64>")
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 <terms_b64> <sig_b64>: 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 <terms_b64> <sig_b64>")
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 <terms_b64> <sig_b64>: 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 <terms_b64> <sig_b64>")
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 <grant nonce> <mode> <params_json>: 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 <grant> <dry_run|apply> <params_json>")
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 <nonce>: 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 <nonce>")
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 ""
}