nodesRunningMe looked for printed lines in what is a JSON list, so every register tool's "also on" hint and every nodes: "all" named no machine.
786 lines
22 KiB
Go
786 lines
22 KiB
Go
package main
|
|
|
|
// The guard on the agent's shell (novox/hq ADR 0245): a PreToolUse hook every session on a machine of the
|
|
// mesh runs before a shell command or a file edit, delivered in the module's plugin.
|
|
//
|
|
// It refuses three work-arounds, each with the mesh tool that does the job when one says it replaces the
|
|
// command, and otherwise with the rule that a missing tool is created, never worked around:
|
|
//
|
|
// 1. **ssh to a mesh machine** — `ssh`, `scp`, `sftp`, `rsync`, `mosh` or `autossh` to a machine's name, a
|
|
// name under its domains, any `*.internal` name, or one of its addresses; a jump through one too. The
|
|
// destination is read as ssh itself reads it (`ssh -G`), so an alias in ~/.ssh/config is no way round.
|
|
// **Stated, not silent**: an ssh login as `git` is the forge's account, which runs git and nothing
|
|
// else — so `git push` over ssh, and `ssh -T git@<forge>`, reach no machine and pass. A git remote is
|
|
// never an ssh command line anyway.
|
|
// 2. **writing /etc/hosts or /etc/resolv.conf** — by redirect, tee, sed -i, an editor, cp/mv/install onto
|
|
// it, or the agent's own Edit and Write tools: a mesh name is the mesh's resolvers' (ADR 0194), and a
|
|
// machine's hosts file is its node-hostname seat's.
|
|
// 3. **HOSTALIASES**, named anywhere in a command line.
|
|
//
|
|
// And it refuses any command that names the operator's override, so the agent never sets it (guard_run.go).
|
|
//
|
|
// The matching is on the command line as a shell would split it — quotes, separators, `$(…)`, `bash -c` —
|
|
// and wrappers such as sudo, env and timeout are looked through. It is a guard against the habit of reaching
|
|
// for ssh, not a sandbox: a command built to hide what it runs can hide it, and the record of what was
|
|
// refused (the module's `guard.log`, read by `claude_code_guard`) is how a habit that found a way round is seen.
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// GuardData is what the hook is given: this machine, the mesh's machines, what replaces what, and where
|
|
// its record is kept. Written beside the hook on every render, root's, so the session cannot change it.
|
|
type GuardData struct {
|
|
Node string `json:"node"`
|
|
Machines []Machine `json:"machines"`
|
|
Replacements []Replacement `json:"replacements"`
|
|
Log string `json:"log"`
|
|
}
|
|
|
|
// HookInput is what the agent hands a PreToolUse hook.
|
|
type HookInput struct {
|
|
ToolName string `json:"tool_name"`
|
|
ToolInput map[string]any `json:"tool_input"`
|
|
Cwd string `json:"cwd"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
// Verdict is the guard's answer.
|
|
type Verdict struct {
|
|
Refuse bool `json:"refuse"`
|
|
Rule string `json:"rule,omitempty"` // ssh | hosts-file | hostaliases | override-named
|
|
Machine string `json:"machine,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
// Overridable is false for a refusal no override lifts: the agent naming the override itself.
|
|
Overridable bool `json:"-"`
|
|
// Calls are the tools the refusal named, by address without a machine: what the instructions' table
|
|
// puts first, the tools the agent most reached round.
|
|
Calls []string `json:"calls,omitempty"`
|
|
}
|
|
|
|
// OverrideVar is the operator's override: set in the operator's own shell before the session starts,
|
|
// with why as its value. Read from the session's own environment as it was started, never from what a
|
|
// command or a setting put there (guard_run.go).
|
|
const OverrideVar = "MESH_GUARD_OVERRIDE"
|
|
|
|
// ForgeUser is the forge's ssh account: it runs git, never a shell (stated in ADR 0245).
|
|
const ForgeUser = "git"
|
|
|
|
// protectedFiles are the files a session never writes for a mesh name.
|
|
var protectedFiles = map[string]bool{"/etc/hosts": true, "/etc/resolv.conf": true}
|
|
|
|
// SSHView is how ssh itself reads a destination with these options: the host it connects to, the user,
|
|
// and the jumps. Empty host when ssh could not say.
|
|
type SSHView func(options []string, destination string) (host, user string, jumps []string)
|
|
|
|
// Guard judges what a session is about to do.
|
|
type Guard struct {
|
|
Data GuardData
|
|
// SSH reads a destination as ssh does (`ssh -G`); nil reads it from the command line alone.
|
|
SSH SSHView
|
|
// Resolve is a name's addresses, for a name that is none of the mesh's but may point at a machine.
|
|
Resolve func(string) []string
|
|
}
|
|
|
|
// Judge is the verdict on one tool call.
|
|
func (g Guard) Judge(in HookInput) Verdict {
|
|
switch in.ToolName {
|
|
case "Bash":
|
|
command, _ := in.ToolInput["command"].(string)
|
|
return g.judgeLine(command, 0)
|
|
case "Edit", "Write", "MultiEdit", "NotebookEdit":
|
|
for _, key := range []string{"file_path", "notebook_path"} {
|
|
if p, _ := in.ToolInput[key].(string); p != "" && protectedFiles[filepath.Clean(p)] {
|
|
return g.hostsFile(filepath.Clean(p), in.ToolName+" "+filepath.Clean(p))
|
|
}
|
|
}
|
|
}
|
|
return Verdict{}
|
|
}
|
|
|
|
func (g Guard) judgeLine(line string, depth int) Verdict {
|
|
if depth > 4 {
|
|
return Verdict{}
|
|
}
|
|
if strings.Contains(line, OverrideVar) {
|
|
return Verdict{Refuse: true, Rule: "override-named", Message: fmt.Sprintf(
|
|
"Refused by the mesh's guard (novox/hq ADR 0245): this command names %s, the operator's override. "+
|
|
"It is the operator's alone, set in their own shell before a session starts; a session never sets, "+
|
|
"reads or passes it.", OverrideVar)}
|
|
}
|
|
if strings.Contains(line, "HOSTALIASES") {
|
|
return g.refuseLocal("hostaliases", "HOSTALIASES", "setting HOSTALIASES")
|
|
}
|
|
for _, words := range simpleCommands(line) {
|
|
if v := g.judgeCommand(words, depth); v.Refuse {
|
|
return v
|
|
}
|
|
// What a word runs in its own shell: `$(…)` and backticks inside a quoted word.
|
|
for _, w := range words {
|
|
for _, inner := range substitutions(w) {
|
|
if v := g.judgeLine(inner, depth+1); v.Refuse {
|
|
return v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return Verdict{}
|
|
}
|
|
|
|
// wrappers run the command after them; each with the options that take a value.
|
|
var wrappers = map[string]string{
|
|
"sudo": "ugCDhpTrt", "doas": "uC", "env": "uSC", "timeout": "sk", "nohup": "", "nice": "n", "ionice": "cnpP",
|
|
"command": "", "exec": "a", "time": "fo", "stdbuf": "ioe", "xargs": "aEdIiLlnPs", "setsid": "", "unbuffer": "",
|
|
"chronic": "", "flock": "wE", "torsocks": "", "proxychains": "f", "proxychains4": "f", "caffeinate": "",
|
|
}
|
|
|
|
// shells run their -c argument as a command line.
|
|
var shells = map[string]bool{"sh": true, "bash": true, "zsh": true, "dash": true, "ksh": true, "fish": true, "eval": true}
|
|
|
|
func (g Guard) judgeCommand(words []string, depth int) Verdict {
|
|
words = unwrap(words)
|
|
if len(words) == 0 {
|
|
return Verdict{}
|
|
}
|
|
name := path.Base(words[0])
|
|
args := words[1:]
|
|
if shells[name] {
|
|
if name == "eval" {
|
|
return g.judgeLine(strings.Join(args, " "), depth+1)
|
|
}
|
|
for i, a := range args {
|
|
if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && strings.Contains(a, "c") && i+1 < len(args) {
|
|
return g.judgeLine(args[i+1], depth+1)
|
|
}
|
|
}
|
|
}
|
|
switch name {
|
|
case "ssh", "autossh", "mosh", "slogin":
|
|
return g.judgeSSH(name, args, strings.Join(words, " "))
|
|
case "scp", "sftp", "rsync":
|
|
return g.judgeCopy(name, args, strings.Join(words, " "))
|
|
}
|
|
if target := writesProtected(name, args); target != "" {
|
|
return g.hostsFile(target, strings.Join(words, " "))
|
|
}
|
|
return Verdict{}
|
|
}
|
|
|
|
// unwrap takes off what only runs the command: assignments before it, and wrappers with their options.
|
|
func unwrap(words []string) []string {
|
|
for len(words) > 0 {
|
|
w := words[0]
|
|
switch {
|
|
case w == "!" || w == "{" || w == "}" || w == "then" || w == "do" || w == "else" || w == "if" || w == "while" ||
|
|
w == "until" || w == "elif":
|
|
words = words[1:]
|
|
case isAssignment(w):
|
|
words = words[1:]
|
|
default:
|
|
opts, wrapper := wrappers[path.Base(w)]
|
|
if !wrapper {
|
|
return words
|
|
}
|
|
base := path.Base(w)
|
|
words = words[1:]
|
|
for len(words) > 0 {
|
|
a := words[0]
|
|
if a == "--" {
|
|
words = words[1:]
|
|
break
|
|
}
|
|
if base == "env" && isAssignment(a) {
|
|
words = words[1:]
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(a, "-") || a == "-" {
|
|
break
|
|
}
|
|
words = words[1:]
|
|
if strings.HasPrefix(a, "--") {
|
|
continue
|
|
}
|
|
if f := a[len(a)-1:]; strings.Contains(opts, f) && len(words) > 0 {
|
|
words = words[1:]
|
|
}
|
|
}
|
|
// timeout's duration, nice's adjustment as a word: a number before the command.
|
|
if (base == "timeout") && len(words) > 0 {
|
|
words = words[1:]
|
|
}
|
|
}
|
|
}
|
|
return words
|
|
}
|
|
|
|
func isAssignment(w string) bool {
|
|
i := strings.Index(w, "=")
|
|
if i <= 0 {
|
|
return false
|
|
}
|
|
for _, r := range w[:i] {
|
|
if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sshOptionsWithValue are ssh's single-letter options that take a value.
|
|
const sshOptionsWithValue = "BbcDEeFIiJLlmOoPpQRSWw"
|
|
|
|
func (g Guard) judgeSSH(name string, args []string, line string) Verdict {
|
|
var options, jumps []string
|
|
user := ""
|
|
dest, rest := "", []string(nil)
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if a == "--" {
|
|
if i+1 < len(args) {
|
|
dest, rest = args[i+1], args[i+2:]
|
|
}
|
|
break
|
|
}
|
|
if name == "autossh" && strings.HasPrefix(a, "-M") {
|
|
if a == "-M" {
|
|
i++ // autossh's own: the monitoring port
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasPrefix(a, "-") && len(a) > 1 {
|
|
options = append(options, a)
|
|
if name == "mosh" || strings.HasPrefix(a, "--") {
|
|
continue // mosh's own options are long, each with its value after `=`
|
|
}
|
|
for j := 1; j < len(a); j++ {
|
|
if !strings.ContainsRune(sshOptionsWithValue, rune(a[j])) {
|
|
continue
|
|
}
|
|
value := a[j+1:]
|
|
if value == "" && i+1 < len(args) {
|
|
i++
|
|
value = args[i]
|
|
options = append(options, value)
|
|
}
|
|
switch a[j] {
|
|
case 'J':
|
|
jumps = append(jumps, strings.Split(value, ",")...)
|
|
case 'l':
|
|
user = value
|
|
case 'o':
|
|
k, v, _ := strings.Cut(value, "=")
|
|
switch strings.ToLower(strings.TrimSpace(k)) {
|
|
case "proxyjump":
|
|
jumps = append(jumps, strings.Split(v, ",")...)
|
|
case "user":
|
|
user = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
dest, rest = a, args[i+1:]
|
|
break
|
|
}
|
|
if dest == "" {
|
|
return Verdict{}
|
|
}
|
|
host, destUser := hostOf(dest)
|
|
if destUser != "" {
|
|
user = destUser
|
|
}
|
|
if g.SSH != nil && name != "mosh" {
|
|
if h, u, j := g.SSH(options, dest); h != "" {
|
|
host, user = h, u
|
|
jumps = append(jumps, j...)
|
|
}
|
|
}
|
|
remote := strings.Join(rest, " ")
|
|
for _, jump := range jumps {
|
|
jh, ju := hostOf(jump)
|
|
if machine, ok := g.meshHost(jh); ok && ju != ForgeUser {
|
|
return g.refuseSSH(machine, jh, remote, line)
|
|
}
|
|
}
|
|
if user == ForgeUser {
|
|
return Verdict{} // the forge's account: git and nothing else (stated in ADR 0245)
|
|
}
|
|
if machine, ok := g.meshHost(host); ok {
|
|
return g.refuseSSH(machine, host, remote, line)
|
|
}
|
|
return Verdict{}
|
|
}
|
|
|
|
// judgeCopy reads scp's, sftp's and rsync's remote paths, `[user@]host:path` or `scp://[user@]host/…`.
|
|
func (g Guard) judgeCopy(name string, args []string, line string) Verdict {
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if strings.HasPrefix(a, "-") {
|
|
if name != "rsync" && len(a) == 2 && strings.ContainsRune("cFiJloPSXBRD", rune(a[1])) {
|
|
i++
|
|
}
|
|
if name == "rsync" && (a == "-e" || a == "--rsh") {
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
var host, user string
|
|
if strings.Contains(a, "://") {
|
|
host, user = hostOf(a)
|
|
} else if colon := strings.Index(a, ":"); colon > 0 && !strings.Contains(a[:colon], "/") {
|
|
host, user = hostOf(a[:colon])
|
|
} else if name == "sftp" {
|
|
host, user = hostOf(a)
|
|
} else {
|
|
continue
|
|
}
|
|
if user == ForgeUser {
|
|
continue
|
|
}
|
|
if g.SSH != nil {
|
|
if h, u, _ := g.SSH(nil, host); h != "" {
|
|
host = h
|
|
if u == ForgeUser && user == "" {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
if machine, ok := g.meshHost(host); ok {
|
|
return g.refuseSSH(machine, host, "", line)
|
|
}
|
|
}
|
|
return Verdict{}
|
|
}
|
|
|
|
// hostOf reads `[user@]host[:port]`, `ssh://[user@]host[:port]/…` or `[v6]`.
|
|
func hostOf(dest string) (host, user string) {
|
|
if strings.Contains(dest, "://") {
|
|
if u, err := url.Parse(dest); err == nil {
|
|
return strings.ToLower(u.Hostname()), u.User.Username()
|
|
}
|
|
}
|
|
if at := strings.LastIndex(dest, "@"); at >= 0 {
|
|
user, dest = dest[:at], dest[at+1:]
|
|
}
|
|
if strings.HasPrefix(dest, "[") {
|
|
if end := strings.Index(dest, "]"); end > 0 {
|
|
return strings.ToLower(dest[1:end]), user
|
|
}
|
|
}
|
|
if h, _, err := net.SplitHostPort(dest); err == nil && strings.Count(dest, ":") == 1 {
|
|
dest = h
|
|
}
|
|
return strings.TrimSuffix(strings.ToLower(dest), "."), user
|
|
}
|
|
|
|
// meshHost says whether a host is one of the mesh's machines, and which when it can tell.
|
|
func (g Guard) meshHost(host string) (string, bool) {
|
|
host = strings.TrimSuffix(strings.ToLower(strings.Trim(host, "[]")), ".")
|
|
if host == "" {
|
|
return "", false
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
for _, m := range g.Data.Machines {
|
|
for _, a := range m.Addresses {
|
|
if b := net.ParseIP(a); b != nil && b.Equal(ip) {
|
|
return m.Name, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
for _, m := range g.Data.Machines {
|
|
if host == strings.ToLower(m.Name) {
|
|
return m.Name, true
|
|
}
|
|
for _, d := range m.Domains {
|
|
if host == d || strings.HasSuffix(host, "."+d) {
|
|
return m.Name, true
|
|
}
|
|
}
|
|
}
|
|
if strings.HasSuffix(host, InternalSuffix) {
|
|
return "", true
|
|
}
|
|
if g.Resolve != nil && !strings.Contains(host, "/") {
|
|
for _, a := range g.Resolve(host) {
|
|
if m, ok := g.meshHost(a); ok && net.ParseIP(a) != nil {
|
|
return m, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// writesProtected is the protected file a command writes, or "".
|
|
func writesProtected(name string, args []string) string {
|
|
protected := func(a string) string {
|
|
a = strings.Trim(a, `"'`)
|
|
if strings.HasPrefix(a, "of=") {
|
|
a = a[3:]
|
|
}
|
|
if protectedFiles[filepath.Clean(a)] {
|
|
return filepath.Clean(a)
|
|
}
|
|
return ""
|
|
}
|
|
// A redirect onto it, from any command.
|
|
for i, a := range args {
|
|
if (a == ">" || a == ">>") && i+1 < len(args) {
|
|
if p := protected(args[i+1]); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
}
|
|
operands := func() []string {
|
|
var out []string
|
|
for i := 0; i < len(args); i++ {
|
|
if args[i] == ">" || args[i] == ">>" || args[i] == "<" {
|
|
i++
|
|
continue
|
|
}
|
|
out = append(out, args[i])
|
|
}
|
|
return out
|
|
}()
|
|
switch name {
|
|
case "tee", "vi", "vim", "nvim", "nano", "emacs", "ed", "ex", "micro", "hx", "helix", "kak", "gedit", "code",
|
|
"truncate", "chattr", "chmod", "chown", "rm", "unlink", "shred", "dd", "sponge", "visudo", "vipw":
|
|
for _, a := range operands {
|
|
if p := protected(a); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
case "sed", "perl", "ruby", "awk", "gawk":
|
|
inPlace := false
|
|
for _, a := range operands {
|
|
switch {
|
|
case name == "awk" || name == "gawk":
|
|
inPlace = inPlace || a == "inplace"
|
|
case strings.HasPrefix(a, "--in-place"):
|
|
inPlace = true
|
|
case strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && strings.Contains(a, "i"):
|
|
inPlace = true
|
|
}
|
|
}
|
|
if inPlace {
|
|
for _, a := range operands {
|
|
if p := protected(a); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
}
|
|
case "cp", "mv", "install", "ln", "rsync":
|
|
var files []string
|
|
for _, a := range operands {
|
|
if !strings.HasPrefix(a, "-") {
|
|
files = append(files, a)
|
|
}
|
|
}
|
|
if len(files) > 1 {
|
|
if p := protected(files[len(files)-1]); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// replacementsFor are the mesh's tools that replace a command, the most specific first, each address once.
|
|
func (g Guard) replacementsFor(command string) []string {
|
|
have := commandWordsOf(command)
|
|
type found struct {
|
|
address string
|
|
weight int
|
|
replace string
|
|
}
|
|
var all []found
|
|
for _, r := range g.Data.Replacements {
|
|
best := 0
|
|
which := ""
|
|
for _, c := range r.Replaces {
|
|
want := commandWordsOf(c)
|
|
if inOrderWords(want, have) && len(want) > best {
|
|
best, which = len(want), c
|
|
}
|
|
}
|
|
if best > 0 {
|
|
all = append(all, found{r.Address, best, which})
|
|
}
|
|
}
|
|
sort.SliceStable(all, func(i, j int) bool { return all[i].weight > all[j].weight })
|
|
var out []string
|
|
for _, f := range all {
|
|
out = append(out, f.address+"\x00"+f.replace)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// callFor is how an address is called for a machine.
|
|
func (g Guard) callFor(address, machine string) string {
|
|
for _, r := range g.Data.Replacements {
|
|
if r.Address != address || !r.NodeScoped {
|
|
continue
|
|
}
|
|
on := machine
|
|
if !r.Seat && len(r.On) > 0 && !containsString(r.On, machine) {
|
|
on = r.On[0]
|
|
}
|
|
if on == "" {
|
|
on = "<node>"
|
|
}
|
|
return on + "/" + address
|
|
}
|
|
return address
|
|
}
|
|
|
|
func containsString(xs []string, s string) bool {
|
|
for _, x := range xs {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// exact is the replacement that names this exact command, or "".
|
|
func (g Guard) exact(command string) string {
|
|
for _, r := range g.Data.Replacements {
|
|
for _, c := range r.Replaces {
|
|
if c == command {
|
|
return r.Address
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
const missingTool = "No mesh tool says it replaces this. Do not work around it: a missing tool is created in the module " +
|
|
"that owns the thing, on the seat it occupies — say what is missing to the operator. First make sure with " +
|
|
"`mesh_search` and the command's own words."
|
|
|
|
func (g Guard) refuseSSH(machine, host, remote, line string) Verdict {
|
|
who := machine
|
|
if who == "" {
|
|
who = host + " (a mesh name)"
|
|
}
|
|
var b strings.Builder
|
|
var named []string
|
|
fmt.Fprintf(&b, "Refused by the mesh's guard (novox/hq ADR 0245): `%s` reaches %s round the mesh's tools.\n", cut(line, 160), who)
|
|
switch {
|
|
case strings.TrimSpace(remote) == "":
|
|
target := machine
|
|
if target == "" {
|
|
target = "<machine>"
|
|
}
|
|
fmt.Fprintf(&b, "What a machine serves is `mesh_machine %s`; `mesh_search` with what you meant to run finds its tool.\n", target)
|
|
default:
|
|
calls := g.replacementsFor(remote)
|
|
if len(calls) == 0 {
|
|
b.WriteString(missingTool + "\n")
|
|
break
|
|
}
|
|
b.WriteString("Call instead, through `mesh_call`:\n")
|
|
for i, c := range calls {
|
|
if i == 3 {
|
|
break
|
|
}
|
|
address, replaces, _ := strings.Cut(c, "\x00")
|
|
named = append(named, address)
|
|
fmt.Fprintf(&b, " %s — replaces `%s`\n", g.callFor(address, machine), replaces)
|
|
}
|
|
}
|
|
return Verdict{Refuse: true, Rule: "ssh", Machine: machine, Message: strings.TrimRight(b.String(), "\n"), Overridable: true,
|
|
Calls: named}
|
|
}
|
|
|
|
func (g Guard) refuseLocal(rule, replaced, what string) Verdict {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Refused by the mesh's guard (novox/hq ADR 0245): %s is a work-around for a mesh name. "+
|
|
"A mesh name is the mesh's resolvers' to answer, and a machine's own lines in /etc/hosts are its node-hostname seat's.\n", what)
|
|
var named []string
|
|
if address := g.exact(replaced); address != "" {
|
|
named = append(named, address)
|
|
fmt.Fprintf(&b, "Call instead, through `mesh_call`: %s — replaces `%s`", g.callFor(address, g.Data.Node), replaced)
|
|
} else {
|
|
b.WriteString(missingTool)
|
|
}
|
|
return Verdict{Refuse: true, Rule: rule, Machine: g.Data.Node, Message: b.String(), Overridable: true, Calls: named}
|
|
}
|
|
|
|
func (g Guard) hostsFile(file, line string) Verdict {
|
|
replaced := "edit " + file
|
|
return g.refuseLocal("hosts-file", replaced, fmt.Sprintf("writing %s (`%s`)", file, cut(line, 120)))
|
|
}
|
|
|
|
func cut(s string, n int) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if r := []rune(s); len(r) > n {
|
|
return string(r[:n-1]) + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// commandWordsOf are a command line's words as matched against a replaced command (the mesh MCP server's search rule):
|
|
// lower-cased, a program by path reduced to its name, sudo left out.
|
|
func commandWordsOf(line string) []string {
|
|
var out []string
|
|
for i, w := range strings.Fields(strings.ToLower(line)) {
|
|
if i == 0 || strings.HasPrefix(w, "/") {
|
|
w = path.Base(w)
|
|
}
|
|
if w == "sudo" {
|
|
continue
|
|
}
|
|
out = append(out, w)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func inOrderWords(want, have []string) bool {
|
|
i := 0
|
|
for _, h := range have {
|
|
if i < len(want) && h == want[i] {
|
|
i++
|
|
}
|
|
}
|
|
return len(want) > 0 && i == len(want)
|
|
}
|
|
|
|
// ---- the shell's words ------------------------------------------------------------------------------
|
|
|
|
// simpleCommands splits a command line as a shell would into its simple commands' words: quotes
|
|
// removed, `;`, `&`, `|`, `&&`, `||`, newlines, parentheses and backticks between commands, `$(` opening
|
|
// one; `>` and `>>` kept as words of their own, before what they redirect to.
|
|
func simpleCommands(line string) [][]string {
|
|
var out [][]string
|
|
var words []string
|
|
var cur strings.Builder
|
|
inWord := false
|
|
flushWord := func() {
|
|
if inWord {
|
|
words = append(words, cur.String())
|
|
cur.Reset()
|
|
inWord = false
|
|
}
|
|
}
|
|
flushCommand := func() {
|
|
flushWord()
|
|
if len(words) > 0 {
|
|
out = append(out, words)
|
|
}
|
|
words = nil
|
|
}
|
|
r := []rune(line)
|
|
for i := 0; i < len(r); i++ {
|
|
c := r[i]
|
|
switch {
|
|
case c == '\\' && i+1 < len(r):
|
|
i++
|
|
if r[i] != '\n' {
|
|
cur.WriteRune(r[i])
|
|
inWord = true
|
|
}
|
|
case c == '\'':
|
|
inWord = true
|
|
for i++; i < len(r) && r[i] != '\''; i++ {
|
|
cur.WriteRune(r[i])
|
|
}
|
|
case c == '"':
|
|
inWord = true
|
|
for i++; i < len(r) && r[i] != '"'; i++ {
|
|
if r[i] == '\\' && i+1 < len(r) && strings.ContainsRune("\"\\$`", r[i+1]) {
|
|
i++
|
|
}
|
|
cur.WriteRune(r[i])
|
|
}
|
|
case c == '$' && i+1 < len(r) && r[i+1] == '(':
|
|
flushCommand()
|
|
i++
|
|
case c == ';' || c == '&' || c == '|' || c == '\n' || c == '(' || c == ')' || c == '`':
|
|
flushCommand()
|
|
case c == '>' || c == '<':
|
|
// A file descriptor before it (`2>`) is not a word.
|
|
if inWord && isDigits(cur.String()) {
|
|
cur.Reset()
|
|
inWord = false
|
|
}
|
|
flushWord()
|
|
op := string(c)
|
|
if c == '>' && i+1 < len(r) && r[i+1] == '>' {
|
|
op = ">>"
|
|
i++
|
|
}
|
|
if i+1 < len(r) && (r[i+1] == '&' || r[i+1] == '|') {
|
|
i++
|
|
}
|
|
words = append(words, op)
|
|
case c == ' ' || c == '\t':
|
|
flushWord()
|
|
case c == '#' && !inWord:
|
|
for i < len(r) && r[i] != '\n' {
|
|
i++
|
|
}
|
|
flushCommand()
|
|
default:
|
|
cur.WriteRune(c)
|
|
inWord = true
|
|
}
|
|
}
|
|
flushCommand()
|
|
return out
|
|
}
|
|
|
|
func isDigits(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// substitutions are the command lines a quoted word runs in a shell of its own: `$(…)` and `…`.
|
|
func substitutions(word string) []string {
|
|
var out []string
|
|
for {
|
|
i := strings.Index(word, "$(")
|
|
if i < 0 {
|
|
break
|
|
}
|
|
rest := word[i+2:]
|
|
depth, end := 1, len(rest)
|
|
for j, c := range rest {
|
|
if c == '(' {
|
|
depth++
|
|
} else if c == ')' {
|
|
depth--
|
|
if depth == 0 {
|
|
end = j
|
|
break
|
|
}
|
|
}
|
|
}
|
|
out = append(out, rest[:end])
|
|
if end >= len(rest) {
|
|
break
|
|
}
|
|
word = rest[end+1:]
|
|
}
|
|
parts := strings.Split(word, "`")
|
|
for i := 1; i < len(parts); i += 2 {
|
|
out = append(out, parts[i])
|
|
}
|
|
return out
|
|
}
|