Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions apps/host_agent/internal/workspaceshell/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: EUPL-1.2
// Package workspaceshell plans initialization without executing or authorizing it.
package workspaceshell

import (
"path"
"regexp"
"strings"
"unicode"
)

// EntryKind describes caller-observed layout data; it proves no filesystem fact.
type EntryKind string

const (
RegularFile EntryKind = "file"
Directory EntryKind = "directory"
)

// ToolboxDescription is caller-supplied data, never an approval or integrity token.
// The future trusted attacher must separately verify bytes, ownership, trust and
// actual read-only mount state before a launcher may apply the resulting plan.
type ToolboxDescription struct {
MountPath, ArtifactDigest, SourceRevision, Platform string
ReadOnly bool
Entries map[string]EntryKind
}

// Plan is data only. Environment must precede all startup/autoload in a future
// new-session launcher. RC fragments belong only in approved respective system rc
// files. Reconnecting an existing console does not apply a new plan.
type Plan struct {
Environment map[string]string
BashRC, ZshRC string
}

// Error is a stable redacted refusal; it never includes caller environment data.
type Error struct{ Code string }

func (e *Error) Error() string { return e.Code }

const root = "/opt/bundledock/toolbox"
const toolboxBin = root + "/bin"
const toolboxShare = root + "/share"

var digest = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
var revision = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`)

var required = map[string]EntryKind{
"bin": Directory,
"share/shell/init.bash": RegularFile,
"share/shell/init.zsh": RegularFile,
"share/fish/vendor_conf.d/bdock-shell.fish": RegularFile,
"share/nushell/vendor/autoload/bdock-shell.nu": RegularFile,
}

// Prepare validates data against the supported profile and computes a pure plan.
// Success does not establish actual attachment, integrity or execution authority.
// Starship is always disabled; there is deliberately no prompt-enable parameter.
func Prepare(environment map[string]string, toolbox *ToolboxDescription) (Plan, error) {
if toolbox != nil {
if toolbox.MountPath != root || !digest.MatchString(toolbox.ArtifactDigest) ||
!revision.MatchString(toolbox.SourceRevision) || !toolbox.ReadOnly ||
(toolbox.Platform != "linux/amd64" && toolbox.Platform != "linux/arm64") {
return Plan{}, &Error{Code: "unsupported_attachment"}
}
for name, kind := range required {
if toolbox.Entries[name] != kind {
return Plan{}, &Error{Code: "unsupported_attachment"}
}
}
}
for key, value := range environment {
if key == "" || strings.ContainsAny(key, "=\x00") || strings.ContainsRune(value, 0) {
return Plan{}, &Error{Code: "invalid_environment"}
}
}
bins, ok := searchEntries(environment["PATH"], toolboxBin)
if !ok || len(bins) == 0 {
return Plan{}, &Error{Code: "invalid_environment"}
}
var shares []string
if value := environment["XDG_DATA_DIRS"]; value != "" {
shares, ok = searchEntries(value, toolboxShare)
if !ok {
return Plan{}, &Error{Code: "invalid_environment"}
}
}
if len(shares) == 0 {
shares = []string{"/usr/local/share", "/usr/share"}
}
out := make(map[string]string, len(environment)+3)
for key, value := range environment {
out[key] = value
}
// Apply the deny fence regardless of attachment/opt-outs/inherited enablement.
out["BDOCK_STARSHIP"] = "0"
shortcuts := toolbox != nil && environment["BDOCK_SHORTCUTS"] != "0"
if toolbox != nil {
bins = append(bins, toolboxBin)
}
if shortcuts {
shares = append([]string{toolboxShare}, shares...)
}
out["PATH"] = strings.Join(bins, ":")
out["XDG_DATA_DIRS"] = strings.Join(shares, ":")
plan := Plan{Environment: out}
if shortcuts {
plan.BashRC = "case $- in *i*) if [ -r /opt/bundledock/toolbox/share/shell/init.bash ]; then . /opt/bundledock/toolbox/share/shell/init.bash; fi ;; esac"
plan.ZshRC = "if [[ -o interactive ]] && [[ -r /opt/bundledock/toolbox/share/shell/init.zsh ]]; then source /opt/bundledock/toolbox/share/shell/init.zsh; fi"
}
return plan, nil
}

// searchEntries preserves source spelling/order; it never canonicalizes paths or
// silently removes malformed/relative entries. Only the exact owned path is cut.
func searchEntries(value, owned string) ([]string, bool) {
entries := strings.Split(value, ":")
out := make([]string, 0, len(entries))
for _, entry := range entries {
if entry == "" || !path.IsAbs(entry) || strings.IndexFunc(entry, unicode.IsControl) >= 0 {
return nil, false
}
if entry != owned {
out = append(out, entry)
}
}
return out, true
}
203 changes: 203 additions & 0 deletions apps/host_agent/internal/workspaceshell/policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: EUPL-1.2
package workspaceshell

import (
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)

const bin = "/opt/bundledock/toolbox/bin"
const share = "/opt/bundledock/toolbox/share"

func toolbox() *ToolboxDescription {
return &ToolboxDescription{MountPath: "/opt/bundledock/toolbox", ArtifactDigest: "sha256:" + strings.Repeat("a", 64), SourceRevision: strings.Repeat("b", 40), Platform: "linux/amd64", ReadOnly: true, Entries: map[string]EntryKind{
"bin": Directory,
"share/shell/init.bash": RegularFile,
"share/shell/init.zsh": RegularFile,
"share/fish/vendor_conf.d/bdock-shell.fish": RegularFile,
"share/nushell/vendor/autoload/bdock-shell.nu": RegularFile,
}}
}

func TestPlanning(t *testing.T) {
cases := []struct {
name string
env map[string]string
absent bool
path, xdg string
rc bool
}{
{"attached defaults", map[string]string{"PATH": "/usr/bin:/bin"}, false, "/usr/bin:/bin:" + bin, share + ":/usr/local/share:/usr/share", true},
{"priority duplicates", map[string]string{"PATH": bin + ":/image/bin:" + bin + ":/bin", "XDG_DATA_DIRS": "/image/share:" + share + ":" + share + ":/usr/share"}, false, "/image/bin:/bin:" + bin, share + ":/image/share:/usr/share", true},
{"absent", map[string]string{"PATH": "/usr/bin:" + bin, "XDG_DATA_DIRS": share + ":/custom"}, true, "/usr/bin", "/custom", false},
{"absent share only", map[string]string{"PATH": "/bin", "XDG_DATA_DIRS": share}, true, "/bin", "/usr/local/share:/usr/share", false},
{"complete optout", map[string]string{"PATH": "/bin", "XDG_DATA_DIRS": share, "BDOCK_SHORTCUTS": "0"}, false, "/bin:" + bin, "/usr/local/share:/usr/share", false},
{"empty xdg", map[string]string{"PATH": "/bin", "XDG_DATA_DIRS": ""}, false, "/bin:" + bin, share + ":/usr/local/share:/usr/share", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.env["BDOCK_STARSHIP"] = "1"
tc.env["BDOCK_ZSH_EXPAND"] = "0"
tc.env["UNCHANGED"] = "line\nvalue"
before := copyMap(tc.env)
d := toolbox()
if tc.absent {
d = nil
}
got, err := Prepare(tc.env, d)
if err != nil {
t.Fatal(err)
}
if got.Environment["PATH"] != tc.path || got.Environment["XDG_DATA_DIRS"] != tc.xdg {
t.Fatalf("unexpected search paths: %#v", got.Environment)
}
if got.Environment["BDOCK_STARSHIP"] != "0" || got.Environment["BDOCK_ZSH_EXPAND"] != "0" || got.Environment["UNCHANGED"] != "line\nvalue" {
t.Fatalf("policy/env lost: %#v", got.Environment)
}
if (got.BashRC != "") != tc.rc || (got.ZshRC != "") != tc.rc {
t.Fatalf("wrong fragments: %#v", got)
}
if !reflect.DeepEqual(tc.env, before) {
t.Fatal("input mutated")
}
again, err := Prepare(got.Environment, d)
if err != nil || !reflect.DeepEqual(got, again) {
t.Fatalf("not idempotent: %#v, %v", again, err)
}
got.Environment["UNCHANGED"] = "changed"
if tc.env["UNCHANGED"] != "line\nvalue" {
t.Fatal("aliased input")
}
})
}
}

func TestDescriptionRefusals(t *testing.T) {
cases := map[string]func(*ToolboxDescription){
"writable": func(d *ToolboxDescription) { d.ReadOnly = false },
"mount": func(d *ToolboxDescription) { d.MountPath = "/tmp/toolbox" },
"mutable artifact": func(d *ToolboxDescription) { d.ArtifactDigest = "toolbox:latest" },
"uppercase digest": func(d *ToolboxDescription) { d.ArtifactDigest = "sha256:" + strings.Repeat("A", 64) },
"source alias": func(d *ToolboxDescription) { d.SourceRevision = "main" },
"platform": func(d *ToolboxDescription) { d.Platform = "darwin/arm64" },
"missing bash": func(d *ToolboxDescription) { delete(d.Entries, "share/shell/init.bash") },
"missing zsh": func(d *ToolboxDescription) { delete(d.Entries, "share/shell/init.zsh") },
"missing fish": func(d *ToolboxDescription) { delete(d.Entries, "share/fish/vendor_conf.d/bdock-shell.fish") },
"missing nu": func(d *ToolboxDescription) { delete(d.Entries, "share/nushell/vendor/autoload/bdock-shell.nu") },
"symlink": func(d *ToolboxDescription) { d.Entries["share/shell/init.bash"] = EntryKind("symlink") },
"bin is file": func(d *ToolboxDescription) { d.Entries["bin"] = RegularFile },
"missing bin": func(d *ToolboxDescription) { delete(d.Entries, "bin") },
"nil entries": func(d *ToolboxDescription) { d.Entries = nil },
"short digest": func(d *ToolboxDescription) { d.ArtifactDigest = "sha256:" + strings.Repeat("a", 63) },
"long digest": func(d *ToolboxDescription) { d.ArtifactDigest = "sha256:" + strings.Repeat("a", 65) },
"short revision": func(d *ToolboxDescription) { d.SourceRevision = strings.Repeat("b", 39) },
"long revision": func(d *ToolboxDescription) { d.SourceRevision = strings.Repeat("b", 41) },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
d := toolbox()
mutate(d)
before := copyEntries(d.Entries)
env := map[string]string{"PATH": "/bin", "BDOCK_STARSHIP": "1"}
p, err := Prepare(env, d)
var e *Error
if !errors.As(err, &e) || e.Code != "unsupported_attachment" {
t.Fatalf("got %v", err)
}
if !reflect.DeepEqual(p, Plan{}) || !reflect.DeepEqual(copyEntries(d.Entries), before) || env["BDOCK_STARSHIP"] != "1" {
t.Fatal("partial output or mutation")
}
})
}
for _, platform := range []string{"linux/amd64", "linux/arm64"} {
d := toolbox()
d.Platform = platform
d.SourceRevision = strings.Repeat("c", 64)
if _, err := Prepare(map[string]string{"PATH": "/bin"}, d); err != nil {
t.Fatal(err)
}
}
}

func TestEnvironmentRefusals(t *testing.T) {
cases := []map[string]string{nil, {}, {"PATH": ""}, {"PATH": bin}, {"PATH": "/bin:"}, {"PATH": ":/bin"}, {"PATH": "/bin::/usr/bin"}, {"PATH": "relative:/bin"}, {"PATH": "/bin\n"}, {"PATH": "/bin", "XDG_DATA_DIRS": "/share:"}, {"PATH": "/bin", "XDG_DATA_DIRS": "relative"}, {"PATH": "/bin", "XDG_DATA_DIRS": "/share\t"}, {"PATH": "/bin", "BAD=KEY": "v"}, {"PATH": "/bin", "": "v"}, {"PATH": "/bin", "V": "a\x00b"}}
for i, env := range cases {
for _, d := range []*ToolboxDescription{nil, toolbox()} {
p, err := Prepare(env, d)
var e *Error
if !errors.As(err, &e) || e.Code != "invalid_environment" || !reflect.DeepEqual(p, Plan{}) {
t.Fatalf("case %d got %#v, %v", i, p, err)
}
}
}
}

func TestFragments(t *testing.T) {
p, err := Prepare(map[string]string{"PATH": "/bin"}, toolbox())
if err != nil {
t.Fatal(err)
}
wantBash := "case $- in *i*) if [ -r /opt/bundledock/toolbox/share/shell/init.bash ]; then . /opt/bundledock/toolbox/share/shell/init.bash; fi ;; esac"
wantZsh := "if [[ -o interactive ]] && [[ -r /opt/bundledock/toolbox/share/shell/init.zsh ]]; then source /opt/bundledock/toolbox/share/shell/init.zsh; fi"
if p.BashRC != wantBash || p.ZshRC != wantZsh {
t.Fatalf("unexpected fragments %#v", p)
}
}

// This exercises authored harmless content at a substituted temporary path.
// It is surrogate evidence, not fixed /opt image/system-rc or vendor acceptance.
func TestBashSnippetSurrogate(t *testing.T) {
p, err := Prepare(map[string]string{"PATH": "/bin:/usr/bin", "BDOCK_STARSHIP": "1"}, toolbox())
if err != nil {
t.Fatal(err)
}
bash, err := exec.LookPath("bash")
if err != nil {
t.Fatalf("required acceptance test needs bash on PATH: %v", err)
}
dir := t.TempDir()
script := filepath.Join(dir, "init.bash")
marker := filepath.Join(dir, "loaded")
prompt := filepath.Join(dir, "prompt")
if err := os.WriteFile(script, []byte("printf loaded > \"$LOAD_MARKER\"\nif [ \"$BDOCK_STARSHIP\" != 0 ]; then printf unsafe > \"$PROMPT_MARKER\"; fi\n"), 0600); err != nil {
t.Fatal(err)
}
snippet := strings.ReplaceAll(p.BashRC, "/opt/bundledock/toolbox/share/shell/init.bash", script)
for _, interactive := range []bool{false, true} {
args := []string{"--noprofile", "--norc", "-c", snippet}
if interactive {
args[2] = "-ic"
}
cmd := exec.Command(bash, args...)
cmd.Env = []string{"PATH=" + p.Environment["PATH"], "BDOCK_STARSHIP=" + p.Environment["BDOCK_STARSHIP"], "LOAD_MARKER=" + marker, "PROMPT_MARKER=" + prompt}
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bash %t: %v %s", interactive, err, out)
}
_, err := os.Stat(marker)
if interactive != (err == nil) {
t.Fatalf("wrong source behavior interactive=%t: %v", interactive, err)
}
if _, err := os.Stat(prompt); !os.IsNotExist(err) {
t.Fatalf("prompt fence failed: %v", err)
}
}
}
func copyMap(in map[string]string) map[string]string {
out := map[string]string{}
for k, v := range in {
out[k] = v
}
return out
}
func copyEntries(in map[string]EntryKind) map[string]EntryKind {
out := map[string]EntryKind{}
for k, v := range in {
out[k] = v
}
return out
}
58 changes: 58 additions & 0 deletions contracts/workspace-shell-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Workspace shell policy port (issue #81)

Status: bounded proposed executable policy; no live integration or trust approval.
[Feature 007](../specs/007-bundledock-shell-policy/spec.md) is the scope authority.

`workspaceshell.Prepare(environment, description)` returns a fresh `Plan` containing
`Environment`, `BashRC` and `ZshRC`, or a typed `Error` with stable `Code`.
The input `ToolboxDescription` is data, not authority: mount path, artifact digest,
source revision, platform, read-only observation and entry kinds. A valid result
only means that these supplied values match this policy's supported profile.
It cannot prove actual bytes, a real mount, integrity, owner approval or execution
trust. The future resolver/attacher must independently establish and record those
facts; never deserialize source-controlled fields as authorization.

Supported profile: `/opt/bundledock/toolbox`; `sha256:` plus 64 lowercase hex;
40/64 lowercase hex source revision; `linux/amd64` or `linux/arm64`; read-only;
`bin` directory and regular files `share/shell/init.bash`, `share/shell/init.zsh`,
`share/fish/vendor_conf.d/bdock-shell.fish`,
`share/nushell/vendor/autoload/bdock-shell.nu`. Missing or unsupported description
returns `unsupported_attachment`. This is a layout compatibility check, not a
filesystem check; symlink resolution/integrity belong to the future attacher.

Environment map keys must be nonempty and contain neither `=` nor NUL; values
must contain no NUL. Search entries are nonempty absolute POSIX paths with no
control characters. No filesystem normalization occurs: only exact fixed toolbox
entries are removed. Reject invalid input as `invalid_environment`; input maps
and entry maps remain untouched, including on failure.

PATH requires a nonempty image search list after stripping exact toolbox bin
occurrences. Append one fixed bin entry iff attached. Strip every exact toolbox
share entry from XDG_DATA_DIRS; missing/empty values or an empty retained list use
`/usr/local/share:/usr/share`. Prepend share iff attached and shortcuts enabled.
Retain all other entry order. Preserve unrelated environment settings.

Force `BDOCK_STARSHIP=0` unconditionally, even with no attachment or an inherited
`1`. `BDOCK_SHORTCUTS=0` leaves toolbox binaries available but omits toolbox share
and both rc fragments: Nushell's unconditional shortcut definitions never become
autoloadable through this plan. `BDOCK_ZSH_EXPAND` passes through. No prompt-enable
mode exists. Opt-outs do not unload prior user definitions or hooks.

BashRC and ZshRC contain only fixed shell-specific snippets with an interactive
check and readable-file guard. The future approved image integration installs each
in the actual respective system rc file. POSIX sh gets neither fragment. Prepare
never sources or executes content. Its returned environment must be applied before
any vendor autoload or system rc starts; setting variables in rc alone is too late.
No output fragment may override the prompt fence. User rc can change environment
and same-user processes can change startup inputs; no hostile-code isolation is
claimed. Image auto-prompt incompatibilities must be rejected by later admission.

New shell/console spawn is the adoption boundary. Reconnecting an existing
persistent console retains its environment; settings require close/new session.
T009 does not accept bundles and is unchanged. T013/T014 will review adapter design
and transport separately. No implicit process, attachment, launch or restart occurs.

Unit tests establish deterministic planning, refusals and idempotence only. Actual
read-only mounts, integrity, four-shell vendor behavior, image rc paths/order and
prompt custom-module sentinels remain future Linux integration acceptance. A fake
fixture or substituted path is surrogate evidence and cannot satisfy those gates.
Loading
Loading