File size: 3,471 Bytes
857a91b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// Package security provides command validation and limit helpers for the
// runtime. The goal for the MVP is a "safe-enough" guard for verified MCP
// servers and short-lived sandboxes, not perfect isolation.
package security

import (
	"fmt"
	"path/filepath"
	"sort"
	"strings"
)

// allowedPrefixes are the only program names a start command may invoke.
var allowedPrefixes = map[string]bool{
	"npx":     true,
	"uvx":     true,
	"pipx":    true,
	"python":  true,
	"python3": true,
	"node":    true,
}

// blockedTokens are exact tokens that may never appear in a command.
var blockedTokens = map[string]bool{
	"sudo":      true,
	"docker":    true,
	"apt":       true,
	"apt-get":   true,
	"apk":       true,
	"yum":       true,
	"dnf":       true,
	"systemctl": true,
	"mkfs":      true,
	"mount":     true,
	"curl":      true,
	"wget":      true,
	"sh":        true,
	"bash":      true,
	"zsh":       true,
	"eval":      true,
}

// shellMetacharacters are forbidden anywhere in the raw command string. They
// enable chaining, redirects, command substitution or backgrounding.
const shellMetacharacters = "&|;<>`$()\n\r"

// ValidateCommand checks a raw start command and returns its tokens when
// accepted. It rejects shell chaining, redirects, backgrounding and any
// program outside the allow-list.
func ValidateCommand(raw string) ([]string, error) {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return nil, fmt.Errorf("empty start command")
	}
	if i := strings.IndexAny(trimmed, shellMetacharacters); i >= 0 {
		return nil, fmt.Errorf("command contains forbidden shell metacharacter %q", trimmed[i])
	}
	tokens, err := SplitCommand(trimmed)
	if err != nil {
		return nil, err
	}
	if len(tokens) == 0 {
		return nil, fmt.Errorf("empty start command")
	}
	base := filepath.Base(tokens[0])
	if !allowedPrefixes[base] {
		return nil, fmt.Errorf("program %q is not allowed (permitted: npx, uvx, pipx, python, python3, node)", base)
	}
	for _, t := range tokens {
		if blockedTokens[filepath.Base(t)] {
			return nil, fmt.Errorf("command contains blocked token %q", t)
		}
	}
	return tokens, nil
}

// SplitCommand tokenises a command line, honouring single and double quotes.
// It is intentionally simple: shell metacharacters are rejected upstream, so
// no escaping, expansion or globbing is performed.
func SplitCommand(s string) ([]string, error) {
	var tokens []string
	var cur strings.Builder
	var quote rune
	inToken := false

	flush := func() {
		if inToken {
			tokens = append(tokens, cur.String())
			cur.Reset()
			inToken = false
		}
	}

	for _, r := range s {
		switch {
		case quote != 0:
			if r == quote {
				quote = 0
			} else {
				cur.WriteRune(r)
			}
		case r == '\'' || r == '"':
			quote = r
			inToken = true
		case r == ' ' || r == '\t':
			flush()
		default:
			inToken = true
			cur.WriteRune(r)
		}
	}
	if quote != 0 {
		return nil, fmt.Errorf("unbalanced quote in command")
	}
	flush()
	return tokens, nil
}

// AllowedPrograms returns the sorted list of permitted start-command programs.
func AllowedPrograms() []string {
	out := make([]string, 0, len(allowedPrefixes))
	for k := range allowedPrefixes {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// BlockedTokens returns the sorted list of tokens refused anywhere in a command.
func BlockedTokens() []string {
	out := make([]string, 0, len(blockedTokens))
	for k := range blockedTokens {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}