feat(addons): add schemaVersion 2 host policy with a deny list

A policy file may now carry a deny object. Deny rules name an add-on ID
or "*" and list prefixes, exact operation names or the single token "*".
Schema 1 files parse as before; a deny key in a schema 1 file, or a
version above 2, is an error that names the problem.

The selected file is now always opened, even when a flat
--addon-host-ops list replaces its grants, because a deny must not
vanish when someone adds a flag. An invalid selected file still stops
startup.

Add --addon-host-deny (LOCALAI_ADDON_HOST_DENY), unioned with the file,
and the --addon-host-blanket and --addon-host-ui-grants flags with their
config options.

Assisted-by: Claude:claude-sonnet-5-5 go
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 21:22:56 +00:00
1 parent 3607c08c17
commit 082fd7ee7d
7 files changed
+442 -104

No files matched your search

+10
View File
@@ -43,6 +43,9 @@ type RunCMD struct {
P2PEnableLimits string `name:"p2p-enable-limits" env:"LOCALAI_P2P_ENABLE_LIMITS" help:"Set to true to enable P2P resource limits" group:"p2p"`
AddonHostPolicy string `name:"addon-host-policy" env:"LOCALAI_ADDON_HOST_POLICY" help:"Absolute path to administrator host operation policy JSON, read once at startup. Empty disables environment policy" group:"addons"`
AddonHostOps string `name:"addon-host-ops" env:"LOCALAI_ADDON_HOST_OPS" help:"Add-ons that may call host operations, as a comma list of addon-id=operation-prefix. An add-on that is not listed gets no host operation channel. Empty gives none" group:"addons"`
AddonHostDeny string `name:"addon-host-deny" env:"LOCALAI_ADDON_HOST_DENY" help:"Host operations no grant may enable, as a comma list of addon-id=operation (a prefix such as egress., an exact name or *). Use *=prefix for every add-on. Deny always wins over grants and is unioned with the deny rules of the policy file" group:"addons"`
AddonHostBlanket string `name:"addon-host-blanket" env:"LOCALAI_ADDON_HOST_BLANKET" help:"Blanket approval of declared host operations at install and upgrade: off, official or any. When set, the UI shows it read-only. Unset leaves the choice to the administrator in the UI" group:"addons"`
AddonHostUIGrants bool `name:"addon-host-ui-grants" env:"LOCALAI_ADDON_HOST_UI_GRANTS" default:"true" help:"Let an administrator grant host operations to add-ons in the UI. Turn it off with --addon-host-ui-grants=false to make the policy file and flags the only source of grants" group:"addons"`
AddonsEnabled bool `name:"addons-enabled" env:"LOCALAI_ADDONS_ENABLED" default:"true" help:"Run the add-on owner, which installs and supervises add-ons. Turn it off with --addons-enabled=false" group:"addons"`
AddonsOwnershipMode string `name:"addons-ownership-mode" env:"LOCALAI_ADDONS_OWNERSHIP_MODE" help:"Who may use add-ons: named-user (each account has its own access; needs authentication) or local-owner (every caller shares one owner). Default: local-owner when authentication is off, named-user when it is on" group:"addons"`
AddonsRole string `name:"addons-role" env:"LOCALAI_ADDONS_ROLE" default:"combined" help:"How this process takes part in the add-on owner: combined (this node is its own owner, the default), owner (only the owner, serves the carrier on the network) or frontend (no owner here; every add-on request goes to the owner). owner and frontend need --addons-owner-address and --addons-carrier-dir" group:"addons"`
@@ -329,6 +332,10 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) {
return err
}
logAddonHostOpsResolution(hostOps)
blanket, err := config.ParseAddonHostBlanket(r.AddonHostBlanket)
if err != nil {
return err
}
diagnosticOpts := r.diagnosticsOptions()
if err := diagnosticOpts.Validate(); err != nil {
@@ -384,6 +391,9 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) {
}
opts := []config.AppOption{
config.WithAddonHostOps(hostOps.Grants),
config.WithAddonHostDeny(hostOps.Deny),
config.WithAddonHostBlanket(blanket),
config.WithAddonHostUIGrantsLocked(!r.AddonHostUIGrants),
config.WithProxyAPIKeyEnvLookup(os.Getenv),
config.WithContext(runCtx),
config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency),
+44 -9
View File
@@ -36,7 +36,7 @@ var _ = Describe("addon host policy CLI precedence", Serial, func() {
return &command.Run
}
BeforeEach(func() {
for _, key := range []string{"LOCALAI_ADDON_HOST_OPS", "LOCALAI_ADDON_HOST_POLICY"} {
for _, key := range []string{"LOCALAI_ADDON_HOST_OPS", "LOCALAI_ADDON_HOST_POLICY", "LOCALAI_ADDON_HOST_DENY"} {
old, ok := os.LookupEnv(key)
DeferCleanup(func() {
if ok {
@@ -57,31 +57,66 @@ var _ = Describe("addon host policy CLI precedence", Serial, func() {
Expect(got.Source).To(Equal("policy:" + p))
Expect(got.Grants).To(Equal(map[string][]string{"custom": {"workspace."}}))
})
It("uses flag presence even when empty and ignores an invalid policy", func() {
writePolicy := func(body string) string {
p := filepath.Join(GinkgoT().TempDir(), "policy.json")
Expect(os.WriteFile(p, []byte(body), 0600)).To(Succeed())
return p
}
It("uses flag presence even when empty, ignores the file grants and still applies its deny", func() {
GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.")
GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", writePolicy(`{"schemaVersion":2,"grants":{"file":["z."]},"deny":{"*":["egress."]}}`))
got, err := parse("--addon-host-ops=").resolveAddonHostOps()
Expect(err).NotTo(HaveOccurred())
Expect(got.Grants).To(BeEmpty())
Expect(got.Source).To(Equal("flag"))
Expect(got.PolicyIgnored).To(BeTrue())
Expect(got.GrantsIgnored).To(BeTrue())
Expect(got.Deny).To(Equal(map[string][]string{"*": {"egress."}}))
})
It("uses explicit empty environment instead of policy", func() {
It("uses explicit empty environment instead of file grants and still applies its deny", func() {
GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "")
GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", writePolicy(`{"schemaVersion":2,"deny":{"a":["*"]}}`))
got, err := parse().resolveAddonHostOps()
Expect(err).NotTo(HaveOccurred())
Expect(got.Grants).To(BeEmpty())
Expect(got.Source).To(Equal("environment"))
Expect(got.PolicyIgnored).To(BeTrue())
Expect(got.GrantsIgnored).To(BeTrue())
Expect(got.Deny).To(Equal(map[string][]string{"a": {"*"}}))
})
It("does not union a nonempty flag with environment or policy", func() {
GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.")
It("stops startup on an invalid selected file even when a flat list replaces its grants", func() {
GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
_, err := parse("--addon-host-ops=flag=y.").resolveAddonHostOps()
Expect(err).To(HaveOccurred())
})
It("does not union a nonempty flag with the file grants", func() {
GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.")
GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", writePolicy(`{"schemaVersion":1,"grants":{"file":["z."]}}`))
got, err := parse("--addon-host-ops=flag=y.").resolveAddonHostOps()
Expect(err).NotTo(HaveOccurred())
Expect(got.Grants).To(Equal(map[string][]string{"flag": {"y."}}))
})
It("unions the deny flag with the file deny", func() {
got, err := parse("--addon-host-policy="+writePolicy(`{"schemaVersion":2,"deny":{"web":["workspace.admin"]}}`), "--addon-host-deny=web=egress.").resolveAddonHostOps()
Expect(err).NotTo(HaveOccurred())
Expect(got.Deny).To(Equal(map[string][]string{"web": {"workspace.admin", "egress."}}))
})
It("reads the deny environment variable", func() {
GinkgoT().Setenv("LOCALAI_ADDON_HOST_DENY", "*=connector.")
got, err := parse("--addon-host-policy=" + writePolicy(`{"schemaVersion":2,"deny":{"web":["workspace.admin"]}}`)).resolveAddonHostOps()
Expect(err).NotTo(HaveOccurred())
Expect(got.Deny).To(Equal(map[string][]string{"web": {"workspace.admin"}, "*": {"connector."}}))
})
It("rejects an invalid deny flag and blanket mode", func() {
_, err := parse("--addon-host-deny=nonsense").resolveAddonHostOps()
Expect(err).To(HaveOccurred())
_, err = config.ParseAddonHostBlanket("sometimes")
Expect(err).To(HaveOccurred())
})
It("leaves UI grants on and the blanket unset by default", func() {
r := parse()
Expect(r.AddonHostUIGrants).To(BeTrue())
Expect(r.AddonHostBlanket).To(BeEmpty())
Expect(parse("--addon-host-ui-grants=false").AddonHostUIGrants).To(BeFalse())
})
It("lets an empty policy flag disable environment policy without restoring defaults", func() {
GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
got, err := parse("--addon-host-policy=").resolveAddonHostOps()
+16 -6
View File
@@ -41,7 +41,13 @@ func (r *RunCMD) resolveAddonHostOps() (config.AddonHostOpsResolution, error) {
} else if value, present := os.LookupEnv("LOCALAI_ADDON_HOST_POLICY"); present {
policy = &value
}
resolved, err := config.ResolveAddonHostOps(flag, environment, policy)
var deny *string
if r.AddonHostDeny != "" {
deny = &r.AddonHostDeny
}
// Kong has already merged the deny environment variable into the field,
// so there is no separate environment value to union.
resolved, err := config.ResolveAddonHostOps(flag, environment, policy, deny, nil)
if err == nil && resolved.Source == "policy" {
resolved.Source = "policy:" + *policy
}
@@ -50,15 +56,19 @@ func (r *RunCMD) resolveAddonHostOps() (config.AddonHostOpsResolution, error) {
func logAddonHostOpsResolution(resolved config.AddonHostOpsResolution) {
if resolved.Source == "empty" {
xlog.Warn("Addons receive no host operations: select --addon-host-policy or explicitly configure --addon-host-ops")
xlog.Warn("Addons receive no host operations from the policy file or flags: an administrator can grant them in the UI at install, or select --addon-host-policy or --addon-host-ops")
}
prefixes := 0
for _, grants := range resolved.Grants {
prefixes += len(grants)
}
xlog.Info("Addon host operation policy resolved", "source", resolved.Source, "addons", len(resolved.Grants), "prefixes", prefixes)
if resolved.PolicyIgnored {
xlog.Warn("Addon host policy ignored: explicit host operations override takes precedence")
denied := 0
for _, entries := range resolved.Deny {
denied += len(entries)
}
xlog.Info("Addon manifests and libraries cannot grant host operations; policy changes require restart")
xlog.Info("Addon host operation policy resolved", "source", resolved.Source, "addons", len(resolved.Grants), "prefixes", prefixes, "denyRules", denied)
if resolved.GrantsIgnored {
xlog.Warn("Addon host policy grants ignored, deny applied: an explicit host operations list replaces the file grants")
}
xlog.Info("Addon manifests and libraries cannot grant host operations; file and flag policy changes require restart")
}
+93
View File
@@ -4,12 +4,14 @@ package config
import (
"fmt"
"regexp"
"slices"
"strings"
)
var (
addonIDForHostOps = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
hostOpPrefix = regexp.MustCompile(`^[a-z]+\.([a-z]+\.)*$`)
hostOpExact = regexp.MustCompile(`^[a-z]+(\.[a-z]+)+$`)
)
// ParseAddonHostOps reads a comma list of addon-id=op-prefix pairs. An add-on
@@ -38,3 +40,94 @@ func WithAddonHostOps(ops map[string][]string) AppOption {
snapshot := CloneAddonHostOps(ops)
return func(o *ApplicationConfig) { o.AddonHostOps = CloneAddonHostOps(snapshot) }
}
// validDenyEntry accepts what a deny rule may name: a prefix (workspace.),
// one exact operation (workspace.admin) or the single token "*".
func validDenyEntry(s string) bool {
return s == "*" || hostOpPrefix.MatchString(s) || hostOpExact.MatchString(s)
}
// ParseAddonHostDeny reads a comma list of addon-id=entry pairs, where addon-id
// may be "*" for every add-on and entry is a prefix, an exact operation name
// or "*". Duplicates are errors; "*" must be the only entry of its add-on.
func ParseAddonHostDeny(s string) (map[string][]string, error) {
out := map[string][]string{}
for _, item := range strings.Split(s, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
id, entry, ok := strings.Cut(item, "=")
id, entry = strings.TrimSpace(id), strings.TrimSpace(entry)
if !ok || !(id == "*" || addonIDForHostOps.MatchString(id)) || !validDenyEntry(entry) {
return nil, fmt.Errorf("invalid host deny entry %q: want addon-id=op-prefix or *=op-prefix, such as *=egress. or custom-addon=*", item)
}
for _, existing := range out[id] {
if existing == entry {
return nil, fmt.Errorf("duplicate host deny entry %q", item)
}
}
out[id] = append(out[id], entry)
}
if len(out) > 128 {
return nil, fmt.Errorf("host deny exceeds 128 addons")
}
for id, entries := range out {
if len(entries) > 32 {
return nil, fmt.Errorf("host deny exceeds 32 entries for %q", id)
}
for _, e := range entries {
if e == "*" && len(entries) != 1 {
return nil, fmt.Errorf(`host deny "*" must be the only entry of %q`, id)
}
}
}
return out, nil
}
// MergeAddonHostDeny unions deny maps. Entries stay in first-seen order and a
// "*" absorbs every other entry of its key. The result is never nil.
func MergeAddonHostDeny(maps ...map[string][]string) map[string][]string {
out := map[string][]string{}
for _, m := range maps {
for id, entries := range m {
for _, e := range entries {
if !slices.Contains(out[id], e) {
out[id] = append(out[id], e)
}
}
}
}
for id, entries := range out {
if slices.Contains(entries, "*") {
out[id] = []string{"*"}
}
}
return out
}
// ParseAddonHostBlanket validates the deployment blanket mode. The empty
// string means the deployment does not set it and the UI decides.
func ParseAddonHostBlanket(s string) (string, error) {
switch s = strings.TrimSpace(s); s {
case "", "off", "official", "any":
return s, nil
}
return "", fmt.Errorf("invalid --addon-host-blanket %q: want off, official or any", s)
}
// WithAddonHostDeny sets the startup deny rules. The map is copied.
func WithAddonHostDeny(deny map[string][]string) AppOption {
snapshot := CloneAddonHostOps(deny)
return func(o *ApplicationConfig) { o.AddonHostDeny = CloneAddonHostOps(snapshot) }
}
// WithAddonHostBlanket sets the deployment blanket mode ("" leaves it to the UI).
func WithAddonHostBlanket(mode string) AppOption {
return func(o *ApplicationConfig) { o.AddonHostBlanket = mode }
}
// WithAddonHostUIGrantsLocked makes the UI grant store read-only.
func WithAddonHostUIGrantsLocked(locked bool) AppOption {
return func(o *ApplicationConfig) { o.AddonHostUIGrantsLocked = locked }
}
+175 -83
View File
@@ -12,19 +12,33 @@ import (
const maxAddonHostPolicyBytes = 64 * 1024
// AddonHostOpsResolution is a startup snapshot. Consumers must not mutate Grants.
// Source and PolicyIgnored are bounded diagnostics, never policy file contents.
// AddonHostOpsResolution is a startup snapshot. Consumers must not mutate
// Grants or Deny. Source and GrantsIgnored are bounded diagnostics, never
// policy file contents.
type AddonHostOpsResolution struct {
Grants map[string][]string
Source string
PolicyIgnored bool
Grants map[string][]string
// Deny is the union of the deny rules of the selected policy file, the
// --addon-host-deny flag and its environment variable. Keys are add-on
// IDs or "*". Values are prefixes, exact operation names or the single
// token "*".
Deny map[string][]string
Source string
// GrantsIgnored is true when a selected policy file had its grants
// replaced by a flat --addon-host-ops list. Its deny rules still apply.
GrantsIgnored bool
}
// ResolveAddonHostOps applies presence-based precedence. Nil means absent;
// a pointer to an empty string is an explicit decision to grant nothing.
// policyPath is already selected by the caller (flag, including empty, over env).
func ResolveAddonHostOps(flag, environment, policyPath *string) (AddonHostOpsResolution, error) {
out := AddonHostOpsResolution{Grants: map[string][]string{}, Source: "empty"}
// ResolveAddonHostOps applies presence-based precedence to grants. Nil means
// absent; a pointer to an empty string is an explicit decision to grant
// nothing. policyPath is already selected by the caller (flag, including
// empty, over env).
//
// A selected policy file is always opened, because its deny rules must
// survive a flat grants list: a deny that disappears when someone adds a flag
// is a security failure. Deny rules from the file, denyFlag and denyEnv are
// unioned.
func ResolveAddonHostOps(flag, environment, policyPath, denyFlag, denyEnv *string) (AddonHostOpsResolution, error) {
out := AddonHostOpsResolution{Grants: map[string][]string{}, Deny: map[string][]string{}, Source: "empty"}
selected := flag
if selected != nil {
out.Source = "flag"
@@ -33,62 +47,104 @@ func ResolveAddonHostOps(flag, environment, policyPath *string) (AddonHostOpsRes
out.Source = "environment"
}
var err error
var file AddonHostPolicy
filePresent := policyPath != nil && *policyPath != ""
if filePresent {
file, err = LoadAddonHostPolicyFile(*policyPath)
if err != nil {
if out.Source == "empty" {
out.Source = "policy"
}
return out, fmt.Errorf("addon host operations (%s): %w", out.Source, err)
}
}
if selected != nil {
out.PolicyIgnored = policyPath != nil && *policyPath != ""
out.GrantsIgnored = filePresent
out.Grants, err = ParseAddonHostOps(*selected)
} else if policyPath != nil && *policyPath != "" {
} else if filePresent {
out.Source = "policy"
out.Grants, err = LoadAddonHostPolicy(*policyPath)
out.Grants = file.Grants
}
if err != nil {
return out, fmt.Errorf("addon host operations (%s): %w", out.Source, err)
}
out.Deny = MergeAddonHostDeny(file.Deny)
for _, raw := range []*string{denyFlag, denyEnv} {
if raw == nil {
continue
}
parsed, perr := ParseAddonHostDeny(*raw)
if perr != nil {
return out, fmt.Errorf("addon host deny: %w", perr)
}
out.Deny = MergeAddonHostDeny(out.Deny, parsed)
}
return out, nil
}
// LoadAddonHostPolicy reads only an explicitly selected administrator file.
// AddonHostPolicy is the decoded content of a policy file.
type AddonHostPolicy struct {
SchemaVersion int
Grants map[string][]string
Deny map[string][]string
}
// LoadAddonHostPolicy reads a policy file and returns only its grants. It
// remains for callers that predate the deny list.
func LoadAddonHostPolicy(path string) (map[string][]string, error) {
p, err := LoadAddonHostPolicyFile(path)
return p.Grants, err
}
// LoadAddonHostPolicyFile reads only an explicitly selected administrator file.
// Ownership and mount permissions are the deployment administrator's responsibility.
// Symlinks to regular files are permitted; this is not a filesystem sandbox.
func LoadAddonHostPolicy(path string) (map[string][]string, error) {
func LoadAddonHostPolicyFile(path string) (AddonHostPolicy, error) {
if !filepath.IsAbs(path) {
return nil, fmt.Errorf("policy path must be absolute")
return AddonHostPolicy{}, fmt.Errorf("policy path must be absolute")
}
// Stat before opening rejects directories and special files (including FIFOs)
// without a potentially blocking read. Recheck the opened descriptor as well.
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat policy: %w", err)
return AddonHostPolicy{}, fmt.Errorf("stat policy: %w", err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("policy must be a regular file")
return AddonHostPolicy{}, fmt.Errorf("policy must be a regular file")
}
// #nosec G304 -- Only the administrator selects this startup policy path via CLI/config; regular-file and 64 KiB checks bound its read.
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open policy: %w", err)
return AddonHostPolicy{}, fmt.Errorf("open policy: %w", err)
}
// Read-only cleanup: close errors cannot change the decoded result.
defer func() { _ = f.Close() }()
info, err = f.Stat()
if err != nil {
return nil, fmt.Errorf("stat opened policy: %w", err)
return AddonHostPolicy{}, fmt.Errorf("stat opened policy: %w", err)
}
if !info.Mode().IsRegular() || info.Size() > maxAddonHostPolicyBytes {
return nil, fmt.Errorf("policy must be a regular file of at most 64 KiB")
return AddonHostPolicy{}, fmt.Errorf("policy must be a regular file of at most 64 KiB")
}
data, err := io.ReadAll(io.LimitReader(f, maxAddonHostPolicyBytes+1))
if err != nil {
return nil, fmt.Errorf("read policy: %w", err)
return AddonHostPolicy{}, fmt.Errorf("read policy: %w", err)
}
if len(data) > maxAddonHostPolicyBytes {
return nil, fmt.Errorf("policy exceeds 64 KiB")
return AddonHostPolicy{}, fmt.Errorf("policy exceeds 64 KiB")
}
return parseAddonHostPolicy(data)
}
// Policy file schema versions this binary reads.
const (
addonHostPolicyV1 = 1
addonHostPolicyV2 = 2
)
// Token decoding deliberately avoids encoding/json struct-field case folding
// and map decoding's last-key-wins behavior.
func parseAddonHostPolicy(data []byte) (map[string][]string, error) {
func parseAddonHostPolicy(data []byte) (AddonHostPolicy, error) {
d := json.NewDecoder(bytes.NewReader(data))
d.UseNumber()
expect := func(want json.Delim) error {
@@ -101,91 +157,127 @@ func parseAddonHostPolicy(data []byte) (map[string][]string, error) {
}
return nil
}
fail := func(err error) (AddonHostPolicy, error) { return AddonHostPolicy{}, err }
if err := expect('{'); err != nil {
return nil, err
return fail(err)
}
seen := map[string]bool{}
grants := map[string][]string{}
out := AddonHostPolicy{Grants: map[string][]string{}, Deny: map[string][]string{}}
for d.More() {
key, err := d.Token()
if err != nil {
return nil, err
return fail(err)
}
name, ok := key.(string)
if !ok || seen[name] {
return nil, fmt.Errorf("duplicate or invalid policy key")
return fail(fmt.Errorf("duplicate or invalid policy key"))
}
seen[name] = true
switch name {
case "schemaVersion":
version, err := d.Token()
if err != nil {
return nil, err
return fail(err)
}
if version != json.Number("1") {
return nil, fmt.Errorf("schemaVersion must be integer 1")
n, isNumber := version.(json.Number)
v, convErr := n.Int64()
switch {
case !isNumber || convErr != nil || v < addonHostPolicyV1:
return fail(fmt.Errorf("schemaVersion must be an integer (supported versions: 1, 2)"))
case v > addonHostPolicyV2:
return fail(fmt.Errorf("policy requires a newer LocalAI (supported versions: 1, 2)"))
}
out.SchemaVersion = int(v)
case "grants":
if err := expect('{'); err != nil {
return nil, err
m, err := readHostOpMap(d, expect, hostOpPrefix.MatchString, false)
if err != nil {
return fail(err)
}
for d.More() {
token, err := d.Token()
if err != nil {
return nil, err
}
id, ok := token.(string)
if !ok || !addonIDForHostOps.MatchString(id) {
return nil, fmt.Errorf("invalid policy addon ID")
}
if _, exists := grants[id]; exists {
return nil, fmt.Errorf("duplicate policy addon ID")
}
if len(grants) >= 128 {
return nil, fmt.Errorf("policy exceeds 128 addons")
}
if err := expect('['); err != nil {
return nil, err
}
prefixes := []string{}
seenPrefixes := map[string]bool{}
for d.More() {
token, err := d.Token()
if err != nil {
return nil, err
}
prefix, ok := token.(string)
if !ok || !hostOpPrefix.MatchString(prefix) || seenPrefixes[prefix] {
return nil, fmt.Errorf("invalid or duplicate policy prefix")
}
if len(prefixes) >= 32 {
return nil, fmt.Errorf("policy exceeds 32 prefixes per addon")
}
prefixes = append(prefixes, prefix)
seenPrefixes[prefix] = true
}
if err := expect(']'); err != nil {
return nil, err
}
grants[id] = prefixes
}
if err := expect('}'); err != nil {
return nil, err
out.Grants = m
case "deny":
m, err := readHostOpMap(d, expect, validDenyEntry, true)
if err != nil {
return fail(err)
}
out.Deny = m
default:
return nil, fmt.Errorf("unknown policy key")
return fail(fmt.Errorf("unknown policy key"))
}
}
if err := expect('}'); err != nil {
return fail(err)
}
if !seen["schemaVersion"] {
return fail(fmt.Errorf("policy requires schemaVersion"))
}
if out.SchemaVersion == addonHostPolicyV1 {
if seen["deny"] {
return fail(fmt.Errorf("deny requires schemaVersion 2"))
}
if !seen["grants"] {
return fail(fmt.Errorf("policy requires schemaVersion and grants"))
}
}
if _, err := d.Token(); err != io.EOF {
return fail(fmt.Errorf("unexpected trailing policy data"))
}
return out, nil
}
// readHostOpMap reads {"id": ["entry", ...]}. For a deny map the key "*" is
// valid and the entry "*" must stand alone.
func readHostOpMap(d *json.Decoder, expect func(json.Delim) error, validEntry func(string) bool, deny bool) (map[string][]string, error) {
if err := expect('{'); err != nil {
return nil, err
}
out := map[string][]string{}
for d.More() {
token, err := d.Token()
if err != nil {
return nil, err
}
id, ok := token.(string)
if !ok || !(addonIDForHostOps.MatchString(id) || (deny && id == "*")) {
return nil, fmt.Errorf("invalid policy addon ID")
}
if _, exists := out[id]; exists {
return nil, fmt.Errorf("duplicate policy addon ID")
}
if len(out) >= 128 {
return nil, fmt.Errorf("policy exceeds 128 addons")
}
if err := expect('['); err != nil {
return nil, err
}
entries := []string{}
seenEntries := map[string]bool{}
for d.More() {
token, err := d.Token()
if err != nil {
return nil, err
}
entry, ok := token.(string)
if !ok || !validEntry(entry) || seenEntries[entry] {
return nil, fmt.Errorf("invalid or duplicate policy prefix")
}
if len(entries) >= 32 {
return nil, fmt.Errorf("policy exceeds 32 prefixes per addon")
}
entries = append(entries, entry)
seenEntries[entry] = true
}
if err := expect(']'); err != nil {
return nil, err
}
if seenEntries["*"] && len(entries) != 1 {
return nil, fmt.Errorf(`policy deny "*" must be the only entry of its list`)
}
out[id] = entries
}
if err := expect('}'); err != nil {
return nil, err
}
if !seen["schemaVersion"] || !seen["grants"] {
return nil, fmt.Errorf("policy requires schemaVersion and grants")
}
if _, err := d.Token(); err != io.EOF {
return nil, fmt.Errorf("unexpected trailing policy data")
}
return grants, nil
return out, nil
}
// CloneAddonHostOps isolates startup grants from caller-owned maps and slices.
+94 -6
View File
@@ -33,7 +33,8 @@ var _ = Describe("Addon host policy", func() {
Entry("missing grants", `{"schemaVersion":1}`),
Entry("null grants", `{"schemaVersion":1,"grants":null}`),
Entry("null prefixes", `{"schemaVersion":1,"grants":{"a":null}}`),
Entry("wrong version", `{"schemaVersion":2,"grants":{}}`),
Entry("version 3", `{"schemaVersion":3,"grants":{}}`),
Entry("version 0", `{"schemaVersion":0,"grants":{}}`),
Entry("string version", `{"schemaVersion":"1","grants":{}}`),
Entry("fraction version", `{"schemaVersion":1.0,"grants":{}}`),
Entry("unknown key", `{"schemaVersion":1,"grants":{},"extra":true}`),
@@ -97,9 +98,9 @@ var _ = Describe("Addon host policy", func() {
_, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{"a":[` + strings.Join(prefixes, ",") + `]}}`))
Expect(err).To(HaveOccurred())
})
It("resolves presence without union or opening an ignored policy", func() {
It("resolves presence without union, always opening the selected policy", func() {
empty, env, flag := "", "env=x.", "flag=y."
bad := "/not-present-policy.json"
bad := write(`{"schemaVersion":2,"grants":{"unused":["q."]},"deny":{"*":["egress."]}}`)
policy := write(`{"schemaVersion":1,"grants":{"file":["z."]}}`)
for _, tc := range []struct {
flag, env, policy *string
@@ -115,14 +116,17 @@ var _ = Describe("Addon host policy", func() {
{nil, &empty, &bad, map[string][]string{}, "environment", true},
{nil, nil, &empty, map[string][]string{}, "empty", false},
} {
got, err := ResolveAddonHostOps(tc.flag, tc.env, tc.policy)
got, err := ResolveAddonHostOps(tc.flag, tc.env, tc.policy, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(got.Grants).To(Equal(tc.want))
Expect(got.Source).To(Equal(tc.source))
Expect(got.PolicyIgnored).To(Equal(tc.ignored))
Expect(got.GrantsIgnored).To(Equal(tc.ignored))
}
_, err := ResolveAddonHostOps(nil, nil, &bad)
missing := "/not-present-policy.json"
_, err := ResolveAddonHostOps(nil, nil, &missing, nil, nil)
Expect(err).To(HaveOccurred())
_, err = ResolveAddonHostOps(&flag, nil, &missing, nil, nil)
Expect(err).To(HaveOccurred(), "an invalid selected file stops startup even when a flat list replaces its grants")
})
It("snapshots programmatic grants including an exact empty decision", func() {
original := map[string][]string{"a": {"x."}}
@@ -138,4 +142,88 @@ var _ = Describe("Addon host policy", func() {
WithAddonHostOps(map[string][]string{})(c)
Expect(c.AddonHostOps).To(BeEmpty())
})
Describe("schemaVersion 2 and the deny list", func() {
It("reads grants and deny, and accepts a deny-only file", func() {
p, err := LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"grants":{"sandbox":["workspace.","egress."]},"deny":{"*":["connector."],"web":["workspace.admin"],"telegram":["*"]}}`))
Expect(err).NotTo(HaveOccurred())
Expect(p.Grants).To(Equal(map[string][]string{"sandbox": {"workspace.", "egress."}}))
Expect(p.Deny).To(Equal(map[string][]string{"*": {"connector."}, "web": {"workspace.admin"}, "telegram": {"*"}}))
p, err = LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{"*":["egress."]}}`))
Expect(err).NotTo(HaveOccurred())
Expect(p.Grants).To(BeEmpty())
Expect(p.Deny).To(HaveLen(1))
})
DescribeTable("rejects", func(body, message string) {
_, err := LoadAddonHostPolicyFile(write(body))
Expect(err).To(MatchError(ContainSubstring(message)))
},
Entry("deny in a v1 file", `{"schemaVersion":1,"grants":{},"deny":{}}`, "deny requires schemaVersion 2"),
Entry("deny before the version in a v1 file", `{"deny":{},"schemaVersion":1,"grants":{}}`, "deny requires schemaVersion 2"),
Entry("v1 without grants", `{"schemaVersion":1}`, "requires schemaVersion and grants"),
Entry("version 3", `{"schemaVersion":3,"grants":{}}`, "requires a newer LocalAI"),
Entry("a version that is not an integer", `{"schemaVersion":"2","grants":{}}`, "supported versions: 1, 2"),
Entry("a missing version", `{"grants":{}}`, "requires schemaVersion"),
Entry("duplicate deny key", `{"schemaVersion":2,"deny":{},"deny":{}}`, "duplicate"),
Entry("duplicate deny add-on", `{"schemaVersion":2,"deny":{"a":["x."],"a":["y."]}}`, "duplicate"),
Entry("duplicate deny entry", `{"schemaVersion":2,"deny":{"a":["x.","x."]}}`, "duplicate"),
Entry("star next to another entry", `{"schemaVersion":2,"deny":{"a":["*","x."]}}`, "only entry"),
Entry("star as a grants key", `{"schemaVersion":2,"grants":{"*":["x."]}}`, "invalid policy addon ID"),
Entry("star as a grant", `{"schemaVersion":2,"grants":{"a":["*"]}}`, "invalid"),
Entry("a bad deny entry", `{"schemaVersion":2,"deny":{"a":["X."]}}`, "invalid"),
Entry("a bad deny key", `{"schemaVersion":2,"deny":{"A":["x."]}}`, "invalid policy addon ID"),
Entry("null deny", `{"schemaVersion":2,"deny":null}`, "expected"),
Entry("unknown key in v2", `{"schemaVersion":2,"grant":{}}`, "unknown policy key"),
Entry("trailing data", `{"schemaVersion":2,"deny":{}} {}`, "trailing"),
)
It("bounds the deny list", func() {
ids := []string{}
for i := 0; i < 129; i++ {
ids = append(ids, fmt.Sprintf(`"a-%d":["x."]`, i))
}
_, err := LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{` + strings.Join(ids[:128], ",") + `}}`))
Expect(err).NotTo(HaveOccurred())
_, err = LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{` + strings.Join(ids, ",") + `}}`))
Expect(err).To(HaveOccurred())
entries := []string{}
for i := 0; i < 33; i++ {
entries = append(entries, fmt.Sprintf(`"x.%s."`, string(rune('a'+i/26))+string(rune('a'+i%26))))
}
_, err = LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{"a":[` + strings.Join(entries[:32], ",") + `]}}`))
Expect(err).NotTo(HaveOccurred())
_, err = LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{"a":[` + strings.Join(entries, ",") + `]}}`))
Expect(err).To(HaveOccurred())
})
It("keeps the 64 KiB file limit", func() {
body := `{"schemaVersion":2,"deny":{}}`
_, err := LoadAddonHostPolicyFile(write(body + strings.Repeat(" ", 65537-len(body))))
Expect(err).To(HaveOccurred())
})
It("parses the flag grammar and unions it with the file", func() {
got, err := ParseAddonHostDeny("*=egress., web=workspace.admin ,telegram=*")
Expect(err).NotTo(HaveOccurred())
Expect(got).To(Equal(map[string][]string{"*": {"egress."}, "web": {"workspace.admin"}, "telegram": {"*"}}))
Expect(ParseAddonHostDeny("")).To(BeEmpty())
for _, bad := range []string{"x", "a=", "A=x.", "a=X.", "a=x.,a=x.", "a=*,a=x.", "=x.", "a=workspace", "*=*,*=x."} {
_, err := ParseAddonHostDeny(bad)
Expect(err).To(HaveOccurred(), bad)
}
Expect(MergeAddonHostDeny(map[string][]string{"a": {"x."}}, map[string][]string{"a": {"x.", "y."}, "b": {"*"}})).
To(Equal(map[string][]string{"a": {"x.", "y."}, "b": {"*"}}))
Expect(MergeAddonHostDeny(map[string][]string{"a": {"x."}}, map[string][]string{"a": {"*"}})).
To(Equal(map[string][]string{"a": {"*"}}))
})
It("unions the file deny with the flag deny and applies it under a flat list", func() {
policy := write(`{"schemaVersion":2,"grants":{"f":["w."]},"deny":{"a":["x."]}}`)
ops, deny := "o=y.", "a=z.,*=*"
got, err := ResolveAddonHostOps(&ops, nil, &policy, &deny, nil)
Expect(err).NotTo(HaveOccurred())
Expect(got.Grants).To(Equal(map[string][]string{"o": {"y."}}))
Expect(got.GrantsIgnored).To(BeTrue())
Expect(got.Deny).To(Equal(map[string][]string{"a": {"x.", "z."}, "*": {"*"}}))
bad := "nonsense"
_, err = ResolveAddonHostOps(nil, nil, &policy, &bad, nil)
Expect(err).To(HaveOccurred())
})
})
})
+10
View File
@@ -100,6 +100,16 @@ type ApplicationConfig struct {
// call. It is startup-only. Nil or empty means no add-on gets a host
// operation endpoint.
AddonHostOps map[string][]string `json:"-" yaml:"-"`
// AddonHostDeny maps an add-on ID, or "*" for all, to host operation
// prefixes, exact names or "*" that no grant may enable. Startup-only;
// deny set in the UI is stored separately and applies live.
AddonHostDeny map[string][]string `json:"-" yaml:"-"`
// AddonHostBlanket is the blanket approval mode set by the deployment
// ("off", "official", "any"). Empty leaves the choice to the UI.
AddonHostBlanket string `json:"-" yaml:"-"`
// AddonHostUIGrantsLocked makes the UI grant store read-only, so the
// policy file and flags are the only source of grants.
AddonHostUIGrantsLocked bool `json:"-" yaml:"-"`
// DiagnosticsRecorder is startup-only and local to this frontend process.
DiagnosticsRecorder *diagnostics.Recorder `json:"-" yaml:"-"`