Skip to content

Commit bd5c98f

Browse files
authored
Merge pull request #67 from AkihiroSuda/linux
Support Linux hosts
2 parents ffc51a7 + 2b52c51 commit bd5c98f

11 files changed

Lines changed: 226 additions & 42 deletions

File tree

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
GOTOOLCHAIN: local
2828
strategy:
2929
matrix:
30-
runs-on: [macos-15, macos-26]
30+
runs-on: [ubuntu-24.04, macos-15, macos-26]
3131
runs-on: ${{ matrix.runs-on }}
3232
timeout-minutes: 10
3333
steps:
@@ -51,7 +51,7 @@ jobs:
5151
lint:
5252
env:
5353
GOTOOLCHAIN: local
54-
runs-on: macos-26
54+
runs-on: ubuntu-24.04
5555
timeout-minutes: 10
5656
steps:
5757
- uses: actions/checkout@v6

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
release:
3232
env:
3333
GOTOOLCHAIN: local
34-
runs-on: macos-26
34+
runs-on: ubuntu-24.04
3535
timeout-minutes: 20
3636
# The maximum access is "read" for PRs from public forked repos
3737
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token

Makefile

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ VERSION_TRIMMED := $(VERSION:v%=%)
77
VERSION_SYMBOL := github.com/AkihiroSuda/alcless/cmd/alclessctl/version.Version
88

99
export SOURCE_DATE_EPOCH ?= $(shell git log -1 --pretty=%ct)
10-
SOURCE_DATE_EPOCH_TOUCH := $(shell date -r $(SOURCE_DATE_EPOCH) +%Y%m%d%H%M.%S)
10+
# BSD date (macOS) uses `-r EPOCH`; GNU date (Linux) uses `-d @EPOCH`.
11+
SOURCE_DATE_EPOCH_TOUCH := $(shell date -r $(SOURCE_DATE_EPOCH) +%Y%m%d%H%M.%S 2>/dev/null || date -d @$(SOURCE_DATE_EPOCH) +%Y%m%d%H%M.%S)
1112

1213
GO ?= go
1314
# Keep symbols by default, for supporting gomodjail
@@ -25,6 +26,13 @@ GOARCH ?= $(shell $(GO) env GOARCH)
2526
BINARIES := _output/bin/alclessctl _output/bin/alcless
2627

2728
TAR ?= tar
29+
# BSD tar (macOS) and GNU tar (Linux) use different flags for reproducible
30+
# archives. `--option !timestamp` and `gzip -n` both omit the gzip header mtime.
31+
ifeq ($(shell $(TAR) --version 2>/dev/null | head -1 | grep -c bsdtar),1)
32+
TAR_REPRODUCIBLE_FLAGS := --uid 0 --gid 0 --option !timestamp -czvf
33+
else
34+
TAR_REPRODUCIBLE_FLAGS := --owner=0 --group=0 --use-compress-program='gzip -n' -cvf
35+
endif
2836

2937
.PHONY: all
3038
all: binaries
@@ -60,30 +68,22 @@ define touch_recursive
6068
find "$(1)" -exec touch -t $(SOURCE_DATE_EPOCH_TOUCH) {} +
6169
endef
6270

63-
to_uname_m = $(shell echo $(1) | sed 's/amd64/x86_64/')
64-
6571
define make_artifact
6672
make clean
67-
GOARCH=$(1) make
73+
GOOS=$(1) GOARCH=$(2) make
6874
$(call touch_recursive,_output)
69-
$(TAR) -C _output/ --no-xattrs --numeric-owner --uid 0 --gid 0 --option !timestamp -czvf _artifacts/alcless-$(VERSION_TRIMMED)-Darwin-$(call to_uname_m,$(1)).tar.gz ./
75+
$(TAR) -C _output/ --no-xattrs --numeric-owner $(TAR_REPRODUCIBLE_FLAGS) _artifacts/alcless-$(VERSION_TRIMMED)-$(3)-$(4).tar.gz ./
7076
endef
7177

72-
# Needs to be executed on macOS
7378
.PHONY: artifacts
7479
artifacts:
7580
rm -rf _artifacts
7681
mkdir -p _artifacts
77-
$(call make_artifact,amd64)
78-
$(call make_artifact,arm64)
82+
$(call make_artifact,darwin,amd64,Darwin,amd64)
83+
$(call make_artifact,darwin,arm64,Darwin,arm64)
84+
$(call make_artifact,linux,amd64,Linux,x86_64)
85+
$(call make_artifact,linux,arm64,Linux,aarch64)
7986
make clean
80-
go version | tee _artifacts/build-env.txt
81-
echo --- >> _artifacts/build-env.txt
82-
sw_vers | tee -a _artifacts/build-env.txt
83-
echo --- >> _artifacts/build-env.txt
84-
pkgutil --pkg-info=com.apple.pkg.CLTools_Executables | tee -a _artifacts/build-env.txt
85-
echo --- >> _artifacts/build-env.txt
86-
$(CC) --version | tee -a _artifacts/build-env.txt
8787
(cd _artifacts ; sha256sum *) > SHA256SUMS
8888
mv SHA256SUMS _artifacts/SHA256SUMS
8989
$(call touch_recursive,_artifacts)

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
# Alcoholless: lightweight security sandbox for Homebrew, AI agents, etc.
88

9-
Alcoholless is a lightweight security sandbox for macOS programs.
9+
Alcoholless is a lightweight security sandbox, primarily for macOS programs.
1010

11-
While Alcoholless was originally made for the sake of securing Homebrew, basically it can be used for almost any CLI programs on macOS.
11+
While Alcoholless was originally made for the sake of securing Homebrew, basically it can be used for almost any CLI programs.
1212
Notably, Alcoholless is useful for allowing an AI agent to run shell commands with less risk of [breaking the host operating system](https://old.reddit.com/r/ClaudeAI/comments/1pgxckk/claude_cli_deleted_my_entire_home_directory_wiped/).
1313

1414
See also my blog article: <https://medium.com/nttlabs/alcoholless-lightweight-security-sandbox-for-macos-ccf0d1927301>
@@ -145,7 +145,7 @@ Select `Launch OpenCode`, press `→`, and choose a model such as `gemma4`.
145145
## Install
146146

147147
Requirements:
148-
- macOS
148+
- macOS (recommended) or Linux
149149
- [Go](https://go.dev)
150150

151151
To install Alcoholless, run:
@@ -214,7 +214,7 @@ See [FAQs](#faqs) for the reason why `su` is wrapped inside `sudo`.
214214

215215
### FAQs
216216
#### Why wrap `su` inside `sudo`?
217-
Because `sudo` doesn't isolate "a specific Mach bootstrap subset, audit session and other characteristics not recognized by POSIX" (see `launchd(8)`),
217+
Because `sudo` doesn't isolate "a specific Mach bootstrap subset, audit session and other characteristics not recognized by POSIX" (see `launchd(8)`) on macOS,
218218
while `su` isolates them.
219219

220220
e.g., `sudo -u alcless_exampleuser_default open -a TextEdit` opens the `TextEdit` application as the current user, not as `alcless_exampleuser_default`.
@@ -224,19 +224,20 @@ however, touching such system configuration files might be scary.
224224

225225
So, the current workaround is to just wrap `su` inside `sudo`.
226226

227+
#### Why not use containers?
228+
Because containers are not supported on macOS.
229+
227230
#### Why not use VM?
228231
Because VM has several disadvantages:
229232
- Non-negligible performance overhead
230233
- High disk consumption
231234
- No direct access to the host hardware (GPU, etc.)
232235
- Localhost address inaccessible from the host
233236
- Does not work on GitHub Actions etc. due to lack of the support for nested virtualization
234-
235-
#### Why not support Linux and FreeBSD?
236-
Because Linux and FreeBSD already have containers.
237+
- [Licensing limitations](https://www.apple.com/legal/sla/) apply for macOS guests (e.g., only 2 guests can be runnable at most)
237238

238239
#### How does Alcoholless relate to Lima?
239-
- Alcoholless (**Lightweight**): run commands as a separate macOS user (not a VM, nor a container)
240+
- Alcoholless (**Lightweight**): run commands as a separate user (not a VM, nor a container)
240241
- [Lima](https://lima-vm.io/) (**Strong security**): run commands in a VM
241242
([Linux](https://lima-vm.io/docs/usage/guests/linux/), [macOS](https://lima-vm.io/docs/usage/guests/macos/), etc.)
242243

cmd/alclessctl/commands/create/create.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ func action(cmd *cobra.Command, args []string) error {
115115
}
116116
}
117117
if !flagPlain {
118-
if err = brew.Installed(ctx, instUser); err == nil {
118+
if !brew.Supported() {
119+
slog.WarnContext(ctx, "Homebrew is not supported on this host", "instance", instName, "instUser", instUser)
120+
} else if err = brew.Installed(ctx, instUser); err == nil {
119121
slog.InfoContext(ctx, "Homebrew is already installed", "instance", instName, "instUser", instUser)
120122
} else {
121123
slog.DebugContext(ctx, "Homebrew is not installed", "instance", instName, "instUser", instUser, "error", err)

cmd/alclessctl/commands/shell/shell.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func New() *cobra.Command {
6767
return cmd
6868
}
6969

70-
// Depth of "/Users/USER" is 3.
70+
// Depth of "/Users/USER" (macOS) and "/home/USER" (Linux) is 3.
7171
const rsyncMinimumSrcDirDepth = 4
7272

7373
func action(cmd *cobra.Command, args []string) error {
@@ -159,7 +159,7 @@ func action(cmd *cobra.Command, args []string) error {
159159
return fmt.Errorf("the host working directory must not be $HOME, as this directory is being rsynced to the instance (Hint: %s)", hint)
160160
} else {
161161
srcWdDepth := len(strings.Split(hostWD, string(os.PathSeparator)))
162-
// Depth of "/Users/USER" is 3
162+
// Depth of "/Users/USER" (macOS) and "/home/USER" (Linux) is 3
163163
slog.DebugContext(ctx, "Working directory depth", "wd", hostWD, "depth", srcWdDepth)
164164
if srcWdDepth < rsyncMinimumSrcDirDepth {
165165
return fmt.Errorf("expected the depth of the host working directory (%q) to be more than %d, only got %d (Hint: %s)",
@@ -186,7 +186,14 @@ func action(cmd *cobra.Command, args []string) error {
186186
}
187187
}
188188

189-
sudoCmd := sudo.Cmd(ctx, instUser, guestWD, cmdExe, cmdArgs)
189+
var sudoOpts []sudo.CmdOpt
190+
if flagTty {
191+
// Allocate a pty (Linux only) so the spawned shell has a controlling
192+
// terminal, avoiding "cannot set terminal process group" and getting
193+
// job control to work.
194+
sudoOpts = append(sudoOpts, sudo.WithPTY())
195+
}
196+
sudoCmd := sudo.Cmd(ctx, instUser, guestWD, cmdExe, cmdArgs, sudoOpts...)
190197
sudoCmdOpts, err := cmdutil.RunOptsFromCobra(cmd) // Propagate stdin
191198
if err != nil {
192199
return err

pkg/brew/brew.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"os/exec"
2525
"os/user"
2626
"path/filepath"
27+
"runtime"
2728

2829
"github.com/AkihiroSuda/alcless/pkg/sudo"
2930
)
@@ -53,14 +54,34 @@ func Installed(ctx context.Context, instUser string) error {
5354
}
5455

5556
func InstallCmds(ctx context.Context, instUser string) []*exec.Cmd {
57+
systemHomebrewPrefix := "/opt/homebrew"
58+
if runtime.GOOS == "linux" {
59+
systemHomebrewPrefix = "/home/linuxbrew/.linuxbrew"
60+
}
5661
cmds := []*exec.Cmd{
5762
// Remove system-wide Homebrew (/opt/homebrew/bin) from the PATH
5863
// Needed since Homebrew 4.5.9 (July 8, 2025)
5964
// https://github.com/AkihiroSuda/alcless/issues/23
60-
sudo.Cmd(ctx, instUser, "", "sh", []string{"-c", `echo 'PATH="$(echo "$PATH" | sed -e s@/opt/homebrew/bin:@@g)"; export PATH' | tee -a "${HOME}/.bash_profile" | tee -a "${HOME}/.bashrc" | tee -a "${HOME}/.zprofile" >> "${HOME}/.zshenv"`}),
65+
sudo.Cmd(ctx, instUser, "", "sh", []string{"-c", `echo 'PATH="$(echo "$PATH" | sed -e s@` + systemHomebrewPrefix + `/bin:@@g)"; export PATH' | tee -a "${HOME}/.bash_profile" | tee -a "${HOME}/.bashrc" | tee -a "${HOME}/.zprofile" >> "${HOME}/.zshenv"`}),
6166

6267
sudo.Cmd(ctx, instUser, "", "git", []string{"clone", "https://github.com/Homebrew/brew", "homebrew"}),
6368
sudo.Cmd(ctx, instUser, "", "sh", []string{"-c", `echo 'eval "$("${HOME}/homebrew/bin/brew" shellenv)"' | tee -a "${HOME}/.bash_profile" >> "${HOME}/.zshenv"`}),
6469
}
6570
return cmds
6671
}
72+
73+
func Supported() bool {
74+
switch runtime.GOOS {
75+
case "darwin":
76+
return true
77+
case "linux":
78+
switch runtime.GOARCH {
79+
case "amd64", "arm64":
80+
return true
81+
default:
82+
return false
83+
}
84+
default:
85+
return false
86+
}
87+
}

pkg/sudo/sudo.go

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"os/exec"
2727
"os/user"
2828
"path/filepath"
29+
"runtime"
2930
"strings"
3031

3132
"al.essio.dev/pkg/shellescape"
@@ -35,23 +36,71 @@ func SudoersPath(instUser string) (string, error) {
3536
return filepath.Join("/etc/sudoers.d/", instUser), nil
3637
}
3738

39+
// suArgs returns the arguments passed to /usr/bin/su, excluding `-c COMMAND`.
40+
//
41+
// On Linux, `-P` allocates a pseudo-terminal so that the spawned shell has a
42+
// controlling terminal and job control works. macOS `su` (BSD) does not
43+
// support `-P`, and the macOS-style invocation already retains the caller's
44+
// tty, so no workaround is needed there.
45+
func suArgs(instUser string, pty bool) []string {
46+
if pty && runtime.GOOS == "linux" {
47+
return []string{"-P", "-", instUser}
48+
}
49+
return []string{"-", instUser}
50+
}
51+
3852
func Sudoers(instUser string) (string, error) {
3953
currentUser, err := user.Current()
4054
if err != nil {
4155
return "", err
4256
}
43-
return fmt.Sprintf("%s ALL=(root) NOPASSWD: /usr/bin/su - %s -c *", currentUser.Username, instUser), nil
57+
// Allow both the plain and the `-P` (Linux pty) forms. Both invocations
58+
// may be issued by alclessctl depending on whether an interactive
59+
// pseudo-terminal is needed.
60+
patterns := []string{
61+
strings.Join(append([]string{"/usr/bin/su"}, suArgs(instUser, false)...), " ") + " -c *",
62+
}
63+
if runtime.GOOS == "linux" {
64+
patterns = append(patterns,
65+
strings.Join(append([]string{"/usr/bin/su"}, suArgs(instUser, true)...), " ")+" -c *",
66+
)
67+
}
68+
return fmt.Sprintf("%s ALL=(root) NOPASSWD: %s", currentUser.Username, strings.Join(patterns, ", ")), nil
69+
}
70+
71+
type cmdOpts struct {
72+
pty bool
73+
}
74+
75+
// CmdOpt is an option for Cmd.
76+
type CmdOpt func(*cmdOpts)
77+
78+
// WithPTY requests that the command be wrapped in a pseudo-terminal,
79+
// so that job control works in interactive shells. Only honored on Linux.
80+
func WithPTY() CmdOpt {
81+
return func(o *cmdOpts) {
82+
o.pty = true
83+
}
4484
}
4585

46-
func Cmd(ctx context.Context, instUser, wd, cmdExe string, cmdArgs []string) *exec.Cmd {
86+
func Cmd(ctx context.Context, instUser, wd, cmdExe string, cmdArgs []string, opts ...CmdOpt) *exec.Cmd {
87+
var o cmdOpts
88+
for _, f := range opts {
89+
f(&o)
90+
}
4791
quotedArgs := make([]string, len(cmdArgs))
4892
for i, f := range cmdArgs {
4993
quotedArgs[i] = shellescape.Quote(f)
5094
}
51-
snippet := fmt.Sprintf("cd %s ; exec %s %s", // cd may fail
52-
shellescape.Quote(wd), // can be empty
95+
execPart := fmt.Sprintf("exec %s %s",
5396
shellescape.Quote(cmdExe),
5497
strings.Join(quotedArgs, " "))
55-
cmd := exec.CommandContext(ctx, "sudo", "-n", "/usr/bin/su", "-", instUser, "-c", snippet)
98+
snippet := execPart
99+
if wd != "" {
100+
snippet = fmt.Sprintf("cd %s ; %s", shellescape.Quote(wd), execPart)
101+
}
102+
args := append([]string{"-n", "/usr/bin/su"}, suArgs(instUser, o.pty)...)
103+
args = append(args, "-c", snippet)
104+
cmd := exec.CommandContext(ctx, "sudo", args...)
56105
return cmd
57106
}

pkg/userutil/userutil.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,9 @@ func Exists(name string) (bool, error) {
5454
}
5555
return true, nil
5656
}
57+
58+
type Attribute string
59+
60+
const (
61+
AttributeUserShell = Attribute("UserShell")
62+
)

pkg/userutil/userutil_darwin.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,6 @@ func Users(ctx context.Context) ([]string, error) {
4848
return res, scanner.Err()
4949
}
5050

51-
type Attribute string
52-
53-
const (
54-
AttributeUserShell = Attribute("UserShell")
55-
)
56-
5751
func ReadAttribute(ctx context.Context, username string, k Attribute) (string, error) {
5852
var stderr bytes.Buffer
5953
cmd := exec.CommandContext(ctx, "dscl", ".", "-read", "/Users/"+username, string(k))

0 commit comments

Comments
 (0)