diff --git a/apps/host_agent/internal/workspaceshell/policy.go b/apps/host_agent/internal/workspaceshell/policy.go new file mode 100644 index 00000000..fae2c85b --- /dev/null +++ b/apps/host_agent/internal/workspaceshell/policy.go @@ -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 +} diff --git a/apps/host_agent/internal/workspaceshell/policy_test.go b/apps/host_agent/internal/workspaceshell/policy_test.go new file mode 100644 index 00000000..da5f8cc1 --- /dev/null +++ b/apps/host_agent/internal/workspaceshell/policy_test.go @@ -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 +} diff --git a/contracts/workspace-shell-policy.md b/contracts/workspace-shell-policy.md new file mode 100644 index 00000000..13684192 --- /dev/null +++ b/contracts/workspace-shell-policy.md @@ -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. diff --git a/docs/development/ci-acceptance.json b/docs/development/ci-acceptance.json index 7ea0d607..c1997b4a 100644 --- a/docs/development/ci-acceptance.json +++ b/docs/development/ci-acceptance.json @@ -957,7 +957,36 @@ "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestResourceResultNumericUsersMatchPinnedCLIDisplay", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestPinnedOfficialCLIReadConfigurationPreservesSourceAndMetadata/resource-result-retains-actual-official-provenance", "github.com/wstein/workharbor-nxt/apps/host_agent/internal/providers/docker/TestRegistryUnknownRawDockerConfigFieldRefusesAfterDigestVerification", - "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRelativeSourceSymlinkUsesConfigurationDiagnostic" + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/config/TestResolverRelativeSourceSymlinkUsesConfigurationDiagnostic", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestPlanning", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestEnvironmentRefusals", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestFragments", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestBashSnippetSurrogate", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestPlanning/attached_defaults", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestPlanning/priority_duplicates", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestPlanning/absent", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestPlanning/absent_share_only", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestPlanning/complete_optout", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestPlanning/empty_xdg", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/writable", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/mount", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/mutable_artifact", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/uppercase_digest", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/source_alias", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/platform", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/missing_bash", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/missing_zsh", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/missing_fish", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/missing_nu", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/symlink", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/bin_is_file", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/missing_bin", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/nil_entries", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/short_digest", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/long_digest", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/short_revision", + "github.com/wstein/workharbor-nxt/apps/host_agent/internal/workspaceshell/TestDescriptionRefusals/long_revision" ], "api_client": [ "github.com/wstein/workharbor-nxt/apps/api_client/wirejson/TestFrozenJSONVectors", diff --git a/docs/index.md b/docs/index.md index 5466320a..9a1236c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,4 +35,5 @@ - [Concept paper: optional AI execution board](papers/optional-ai-execution.md) - [Proposed external orchestrator adapters (wave two, outside pilot)](design/orchestrator-adapters.md) - [BundleDock workspace shell integration (proposal)](papers/bundledock-workspace-shell.md) +- [Bounded workspace shell policy contract (candidate)](../contracts/workspace-shell-policy.md) - [Source references](references.md) diff --git a/docs/papers/bundledock-workspace-shell.md b/docs/papers/bundledock-workspace-shell.md index bd851921..b5f5fba3 100644 --- a/docs/papers/bundledock-workspace-shell.md +++ b/docs/papers/bundledock-workspace-shell.md @@ -15,13 +15,15 @@ agent-written prompt commands. Issue #81 calls this future bundle integration "feature 002". That identifier is already used by [Symphony documentation smoke](../../specs/002-symphony-doc-smoke/spec.md). -Allocate a distinct feature identifier before specifying BundleDock integration; -do not repurpose that directory or change completed task history. Current `main` +The bounded [Feature 007 policy port](../../specs/007-bundledock-shell-policy/spec.md) +now uses a distinct identifier; it does not repurpose that directory or change +completed task history. Its [data-only contract](../../contracts/workspace-shell-policy.md) +implements deterministic planning with Starship off, not attachment or launch. Current `main` has no product attachment implementation. The installable pilot does not supply an approved BundleDock attachment contract either. -The future feature needs a reviewed spec, plan, tasks and bounded implementation -scope. It must choose the attachment method, supported image/shell matrix, +The broader integration still needs a reviewed spec, plan, tasks and bounded +implementation scope. It must choose the attachment method, supported image/shell matrix, immutable artifact and prompt policy. This paper supplies review input without inventing approved tasks or changing the accepted bundle ports. diff --git a/docs/quality/issue81-shell-policy.md b/docs/quality/issue81-shell-policy.md new file mode 100644 index 00000000..bcee0140 --- /dev/null +++ b/docs/quality/issue81-shell-policy.md @@ -0,0 +1,62 @@ +# Issue 81 bounded shell-policy evidence + +2026-10-15. Candidate implementation; full workspace integration remains unverified. +Scope: [Feature 007](../../specs/007-bundledock-shell-policy/spec.md) and the +[data-only policy contract](../../contracts/workspace-shell-policy.md). + +## Source and review boundary + +The candidate depends on the unmerged installable-pilot baseline +`832dd63000257bad01656154b639251d643f2a2d`. Its T009 source is `90ea176`; +this slice leaves the closed provider/config schemas and existing task markers +unchanged. The main-target draft must not imply the pilot prerequisites reached +main. Design PR [#82](https://github.com/wstein/workharbor/pull/82) was owner-merged; +its documentation and repository-rename CI fix are retained in this dependency +branch. The runtime policy is new follow-up work under the direct implementation +request; the historical issue's automatic closure does not prove integration. + +An independent Sol design review cleared the concrete spec/plan/tasks and contract +after correcting share-only XDG fallback idempotence and requiring snippet-local +interactive guards. Independent implementation review found no code/security +findings at `c3ce22af433941b2899cb268f6925aabbfc8215b`; commit-message corrections +produced the identical source tree at `036e8e02345b7d307904659e1432deb496a0dd36`. +Both SHAs are pre-rebase; the review of the current rebased head is recorded on +PR [#83](https://github.com/wstein/workharbor/pull/83). Final exact-head +review/check evidence belongs to the PR handoff. + +## Executed evidence + +- Failure-first `go test ./internal/workspaceshell`: initial absent-API compilation + failure, then a compiled refusal-only stub failed the intended successful planning, + environment refusal, fragment and surrogate behavior cases. No failure was called + passing. Implementation followed those observed failures. +- `GOCACHE= go test -count=1 ./internal/workspaceshell`: PASS. +- `GOCACHE= go vet ./internal/workspaceshell`: PASS. +- `gofmt -l internal/workspaceshell`: no output (formatted). +- Bash surrogate test: authored harmless fixture at a substituted temporary path; + noninteractive mode did not source it, interactive mode did, and the prompt + sentinel was absent with the planned deny environment. +- Manual `/bin/zsh -f -c` and `-ic` surrogate checks: same guard/sentinel outcomes. +- `git diff --check`: PASS at the source candidate. + +The default Go build cache initially refused a fresh check under native sandbox +permissions. Repeating with a task-local writable GOCACHE passed; no permission +widening or missing check was concealed. Native Nushell is unavailable here. +Repository canonical checks are tracked separately at the final head in the PR; +no pending local run is claimed passing by this record. + +## Remaining adoption and acceptance + +This package has no caller in the current pilot: it deliberately supplies a pure +port for the future T013/T014 new-session launcher. A valid description/plan grants +no execution authorization and proves no real mount or artifact bytes. The future +resolver/attacher must establish immutable digest integrity, source provenance, +platform, ownership/trust and observed read-only layout. Actual supported image rc +paths, fixed `/opt` sourcing, all-four-shell vendor autoload, prompt-rendering +sentinels, noninteractive startup, Docker mount behavior and lifecycle/reconnect +acceptance remain unexecuted. Bash/zsh surrogate checks cannot satisfy those gates. + +Always disabling BundleDock Starship avoids choosing the open shared-HOME trust +mode. It does not isolate same-user agents from user rc edits or existing prompts. +No carrier is built/published, attachment installed, shell launched for a user, +runtime/dispatch activated, or deployment performed by this policy slice. diff --git a/specs/007-bundledock-shell-policy/plan.md b/specs/007-bundledock-shell-policy/plan.md new file mode 100644 index 00000000..c8eb70f2 --- /dev/null +++ b/specs/007-bundledock-shell-policy/plan.md @@ -0,0 +1,37 @@ +# Feature 007 implementation plan + +Status: proposed for owner review; bounded direct-request implementation candidate. +No revision-bound Symphony dispatch approval is asserted. + +Use the existing pilot host-agent Go module and standard library only. Depend on +pilot `832dd63000257bad01656154b639251d643f2a2d` (T009 runtime source +`90ea176`), which is not in main. Keep the main-target PR draft with this explicit +dependency. Do not change Feature 006 tasks, the T009 closed config/provider schema, +mount admission, Docker execution or transport fields. + +Add `internal/workspaceshell` as a pure policy port. It validates caller-supplied +attachment description only; later trusted resolver/attacher establishes integrity, +authorization, and observed mount state. Never decode source data into authority. +The new-session launcher owns applying the returned environment before startup and +installing fixed guarded rc fragments in approved image paths. Reattachment to an +existing console cannot apply settings. T013/T014 have no accepted launch adapter +API yet, so this port deliberately requires later adoption review. + +Write negative/positive tests before behavior, then implement environment planning. +Use fixed toolbox paths and shell snippets, copied environment maps and stable typed +errors. No shell-string interpolation, source execution, dependencies or activation. +Document no-prompt policy and full Nushell shortcuts opt-out. Keep executable policy +acceptance separate from full runtime acceptance. + +Allowed paths: this feature directory; `contracts/workspace-shell-policy.md`; +`apps/host_agent/internal/workspaceshell/`; append only this package's required +IDs in `docs/development/ci-acceptance.json`; a focused evidence record and links in +`docs/index.md` and the issue-81 design paper. No existing accepted task edits. + +Checks from host-agent: `gofmt -l internal/workspaceshell`, +`go test ./internal/workspaceshell`, `go vet ./internal/workspaceshell`. +From root: `make check`, `git diff --check`. Record actual commands/results. +Real Linux read-only mounts, image-specific system rc loading, bash/zsh/fish/Nushell +vendor startup and prompt-sentinel tests remain future integration acceptance; +fixtures and pure outputs cannot satisfy them. Independent exact-head review is +required before handoff; passing checks do not grant merge or activation. diff --git a/specs/007-bundledock-shell-policy/spec.md b/specs/007-bundledock-shell-policy/spec.md new file mode 100644 index 00000000..2d2d5855 --- /dev/null +++ b/specs/007-bundledock-shell-policy/spec.md @@ -0,0 +1,52 @@ +# Feature 007: BundleDock workspace shell policy + +Status: proposed owner-review scope for issue #81. The direct implementation request +covers a bounded executable policy port with Starship disabled; it does not approve +prompt trust, artifact execution, attachment or activation. Feature 002 remains the +Symphony smoke feature. Full workspace integration is not implemented by this slice. + +## User scenario + +US1: a future trusted workspace launcher obtains a deterministic shell environment +and fixed bash/zsh system-rc fragments for a previously authorized, immutable, +read-only toolbox. Image binaries retain priority. Agent-writable prompt inputs +never cause this policy to enable BundleDock Starship. + +## Requirements + +- FR-001: Supply a pure host-agent policy port. It performs no filesystem, network, + subprocess, mount or provider effects and grants no attachment or execution trust. + Caller authorization and observed artifact/mount verification remain mandatory. +- FR-002: Admit only the fixed toolbox layout at `/opt/bundledock/toolbox`, Linux + amd64/arm64, immutable SHA-256 artifact and exact source revision. Reject missing + required shell entry points or unsupported observations. Absence is explicit. +- FR-003: Force `BDOCK_STARSHIP=0` in every returned environment, including absent + attachment and inherited enablement. No prompt enablement API exists. +- FR-004: Remove every exact toolbox bin/share entry, append bin last and prepend + share first once when attached and shortcuts enabled. Preserve other entries in + order. Unset/empty XDG data directories and lists emptied by exact toolbox removal + retain `/usr/local/share:/usr/share`. + Empty, relative or malformed search entries refuse; PATH must remain nonempty. +- FR-005: `BDOCK_SHORTCUTS=0` omits toolbox share and both rc fragments, providing + complete Nushell opt-out while preserving toolbox binaries. Other opt-outs pass + through; they cannot override FR-003. Return fixed readable-file and interactive guarded bash/zsh fragments + only for their interactive system rc files; never source them from POSIX sh. +- FR-006: Do not mutate caller data. Repeated application is idempotent. Attachment + removal strips stale exact paths and returns no startup fragments. +- FR-007: Future adoption applies the result before a new shell or console can + autoload; reconnect to an existing persistent session must not claim environment + changes. Same-user code can still change its own startup files and environment; + this is initialization policy, not hostile-process isolation. + +## Success criteria and limits + +SC-001: independent failure-first table tests demonstrate descriptor/environment +refusals, order, defaults, inherited enablement denial, opt-out, immutability, +absence/removal and idempotence. SC-002: format, focused Go tests/vet and repository +checks pass on the reviewed head. SC-003: source contracts identify the later +resolver, attacher and new-session launcher responsibilities without widening T009. + +A unit-tested plan does not prove a mount exists, digest integrity, execution trust, +actual vendor autoload, shell startup ordering or prompt rendering. Native Docker +and all-four-shell acceptance are future integration gates, recorded as unverified. +This slice does not establish full issue-81 integration or its remaining evidence. diff --git a/specs/007-bundledock-shell-policy/tasks.md b/specs/007-bundledock-shell-policy/tasks.md new file mode 100644 index 00000000..e425afe3 --- /dev/null +++ b/specs/007-bundledock-shell-policy/tasks.md @@ -0,0 +1,16 @@ +# Feature 007 tasks + +Status: proposed bounded direct-request scope, not approved Symphony dispatch. + +## US1: Deterministic safe shell initialization plan + +- [ ] T001 [US1] Specify the pure launch-policy contract, author failing table tests, implement `apps/host_agent/internal/workspaceshell/`, and record focused Go/repository checks plus independent review. Validate fixed-layout attachment descriptions; force Starship off; preserve PATH priority/XDG defaults; implement complete shortcuts opt-out, absence/removal, input immutability and idempotence (FR-001–FR-007; SC-001–SC-003). + +## Dependencies and future adoption + +T001 depends on the existing pilot host-agent module at +`832dd63000257bad01656154b639251d643f2a2d`; it does not depend on a working resolver, +attacher or launcher because it executes none. Those missing prerequisites block +full issue-81 integration and any runtime adoption, not pure policy unit tests. +No additional tasks are invented for other owners. Feature006 T013/T014 will choose +and review the actual launch adapter after their own prerequisites are satisfied.