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
7 changes: 7 additions & 0 deletions cli/beacon/cmd/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ func init() {
endpointHooksCmd.AddCommand(endpointHooksInstallCmd)
endpointHooksCmd.AddCommand(endpointHooksUninstallCmd)
endpointHooksCmd.AddCommand(endpointHooksStatusCmd)
endpointHooksCmd.AddCommand(endpointHooksRepairInstalledCmd)
endpointCoworkCmd.AddCommand(endpointCoworkPrintConfigCmd)
endpointCoworkCmd.AddCommand(endpointCoworkSetupCmd)
endpointCoworkCmd.AddCommand(endpointCoworkStatusCmd)
Expand Down Expand Up @@ -505,6 +506,12 @@ func init() {
c.Flags().StringVar(&endpointOpts.hookHarnesses, "harness", "cursor", "Comma-separated hook harnesses")
c.Flags().StringVar(&endpointOpts.hookLevel, "level", "user", "Hook install level: user or project")
}
endpointHooksRepairInstalledCmd.Flags().BoolVar(&endpointOpts.userMode, "user", true, "Use per-user endpoint paths")
endpointHooksRepairInstalledCmd.Flags().BoolVar(&endpointOpts.systemMode, "system", false, "Use system endpoint paths and launch daemon")
endpointHooksRepairInstalledCmd.Flags().StringVar(&endpointOpts.logPath, "log-path", "", "Runtime JSONL log path")
endpointHooksRepairInstalledCmd.Flags().StringVar(&endpointOpts.hookHarnesses, "harness", "", "Comma-separated hook harnesses to refresh; empty refreshes already-installed Beacon hooks")
endpointHooksRepairInstalledCmd.Flags().StringVar(&endpointOpts.hookLevel, "level", "user", "Hook install level: user or project")
endpointHooksRepairInstalledCmd.Flags().BoolVar(&endpointOpts.jsonOutput, "json", false, "Print repair result as JSON")
endpointHooksInstallCmd.Flags().BoolVar(&endpointOpts.allTargets, "all", false, "Target all supported hook harnesses")
endpointHooksInstallCmd.Flags().BoolVar(&endpointOpts.dryRun, "dry-run", false, "Print planned hook actions without changing files")
endpointHooksUninstallCmd.Flags().BoolVar(&endpointOpts.allTargets, "all", false, "Target all supported hook harnesses")
Expand Down
196 changes: 196 additions & 0 deletions cli/beacon/cmd/endpoint_hooks_repair.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"time"

endpointconfig "github.com/asymptote-labs/agent-beacon/cli/beacon/internal/endpoint/config"
"github.com/asymptote-labs/agent-beacon/cli/beacon/internal/endpoint/writer"
"github.com/spf13/cobra"
)

var endpointHooksRepairInstalledCmd = &cobra.Command{
Use: "repair-installed",
Short: "Refresh installed user hooks",
Hidden: true,
SilenceUsage: true,
RunE: runEndpointHooksRepairInstalled,
}

type consoleUserInfo struct {
Username string
HomeDir string
}

type endpointHookRepairResult struct {
User string `json:"user,omitempty"`
HomeDir string `json:"home_dir,omitempty"`
RuntimeLogPath string `json:"runtime_log_path,omitempty"`
Targets []string `json:"targets,omitempty"`
SkippedReason string `json:"skipped_reason,omitempty"`
}

var (
activeConsoleUser = defaultActiveConsoleUser
runHookRepairAsUser = defaultRunHookRepairAsUser
)

func runEndpointHooksRepairInstalled(cmd *cobra.Command, args []string) error {
result, err := repairInstalledEndpointHooks()
if endpointOpts.jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(result)
}
return err
}

func repairInstalledEndpointHooks() (endpointHookRepairResult, error) {
info, ok, err := activeConsoleUser()
if err != nil {
return endpointHookRepairResult{}, err
}
if !ok {
return endpointHookRepairResult{SkippedReason: "no_active_console_user"}, nil
}

logPath := endpointOpts.logPath
if logPath == "" {
cfg := loadConfigForMode(false, "")
logPath = cfg.LogPath
}
if logPath == "" {
logPath = writer.DefaultPath(false)
}

targets, err := repairHookTargetsForUser(info, logPath)
if err != nil {
return endpointHookRepairResult{}, err
}
return endpointHookRepairResult{
User: info.Username,
HomeDir: info.HomeDir,
RuntimeLogPath: logPath,
Targets: targets,
}, nil
}

func repairHookTargetsForUser(info consoleUserInfo, logPath string) ([]string, error) {
targetSet := map[string]bool{}
if strings.TrimSpace(endpointOpts.hookHarnesses) != "" {
targets, err := canonicalHookTargets(splitCSV(endpointOpts.hookHarnesses))
if err != nil {
return nil, err
}
for _, target := range targets {
targetSet[target] = true
}
}
installed, err := installedHookTargetsForUser(info.HomeDir, logPath)
if err != nil {
return nil, err
}
for _, target := range installed {
targetSet[target] = true
}
targets := orderedRepairTargets(targetSet)
if len(targets) == 0 {
return nil, nil
}
args := []string{"endpoint", "hooks", "install", "--harness", strings.Join(targets, ","), "--level", endpointOpts.hookLevel, "--log-path", logPath}
if err := runHookRepairAsUser(info, args...); err != nil {
return targets, err
}
return targets, nil
}

func orderedRepairTargets(targetSet map[string]bool) []string {
targets := []string{}
for _, target := range repairTargetOrder() {
if targetSet[target] {
targets = append(targets, target)
}
}
return targets
}

func installedHookTargetsForUser(homeDir, logPath string) ([]string, error) {
candidates, err := canonicalHookTargets(repairTargetOrder())
if err != nil {
return nil, err
}
return withUserHome(homeDir, func() ([]string, error) {
cfg := endpointconfig.Default(false, logPath)
statuses := hookStatusesWithConfig(candidates, cfg)
targets := []string{}
for _, name := range candidates {
if status, ok := statuses[name]; ok && status.Installed {
targets = append(targets, name)
}
}
return targets, nil
})
}

func repairTargetOrder() []string {
return []string{"claude", "codex", "cursor", "vscode", "factory", "opencode", "grok", "hermes", "devin-cli", "devin-desktop", "antigravity"}
}

func withUserHome[T any](homeDir string, fn func() (T, error)) (T, error) {
oldHome, hadHome := os.LookupEnv("HOME")
_ = os.Setenv("HOME", homeDir)
defer func() {
if hadHome {
_ = os.Setenv("HOME", oldHome)
} else {
_ = os.Unsetenv("HOME")
}
}()
return fn()
}

func defaultActiveConsoleUser() (consoleUserInfo, bool, error) {
if runtime.GOOS != "darwin" {
return consoleUserInfo{}, false, nil
}
out, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
if err != nil {
return consoleUserInfo{}, false, err
}
username := strings.TrimSpace(string(out))
if username == "" || username == "root" || username == "loginwindow" {
return consoleUserInfo{}, false, nil
}
homeOut, err := exec.Command("dscl", ".", "-read", "/Users/"+username, "NFSHomeDirectory").Output()
if err != nil {
return consoleUserInfo{}, false, err
}
home := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(homeOut)), "NFSHomeDirectory:"))
if home == "" {
return consoleUserInfo{}, false, fmt.Errorf("could not resolve home directory for %s", username)
}
return consoleUserInfo{Username: username, HomeDir: home}, true, nil
}

func defaultRunHookRepairAsUser(info consoleUserInfo, args ...string) error {
bin, err := os.Executable()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmdArgs := []string{"-u", info.Username, "env", "HOME=" + info.HomeDir, "USER=" + info.Username, "LOGNAME=" + info.Username, bin}
cmdArgs = append(cmdArgs, args...)
out, err := exec.CommandContext(ctx, "sudo", cmdArgs...).CombinedOutput()
if err != nil {
return fmt.Errorf("refresh hooks for %s: %s: %w", info.Username, strings.TrimSpace(string(out)), err)
}
if text := strings.TrimSpace(string(out)); text != "" {
fmt.Println(text)
}
return nil
}
89 changes: 89 additions & 0 deletions cli/beacon/cmd/endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,95 @@ func TestEndpointInventoryHooksJSONIncludesDefaultHookStatuses(t *testing.T) {
}
}

func TestRepairInstalledEndpointHooksUsesExplicitHarnesses(t *testing.T) {
oldOpts := endpointOpts
oldConsoleUser := activeConsoleUser
oldRunAsUser := runHookRepairAsUser
t.Cleanup(func() {
endpointOpts = oldOpts
activeConsoleUser = oldConsoleUser
runHookRepairAsUser = oldRunAsUser
})

home := t.TempDir()
logPath := filepath.Join(t.TempDir(), "runtime.jsonl")
activeConsoleUser = func() (consoleUserInfo, bool, error) {
return consoleUserInfo{Username: "shad", HomeDir: home}, true, nil
}
var gotUser consoleUserInfo
var gotArgs []string
runHookRepairAsUser = func(info consoleUserInfo, args ...string) error {
gotUser = info
gotArgs = append([]string(nil), args...)
return nil
}
endpointOpts.logPath = logPath
endpointOpts.hookHarnesses = "claude,codex,cursor"
endpointOpts.hookLevel = "user"

result, err := repairInstalledEndpointHooks()
if err != nil {
t.Fatalf("repairInstalledEndpointHooks returned error: %v", err)
}
if result.User != "shad" || result.HomeDir != home {
t.Fatalf("result user/home = %#v", result)
}
wantArgs := []string{"endpoint", "hooks", "install", "--harness", "claude,codex,cursor", "--level", "user", "--log-path", logPath}
if strings.Join(gotArgs, "\x00") != strings.Join(wantArgs, "\x00") {
t.Fatalf("repair args = %#v, want %#v", gotArgs, wantArgs)
}
if gotUser.Username != "shad" || gotUser.HomeDir != home {
t.Fatalf("run user = %#v, want shad/%s", gotUser, home)
}
}

func TestRepairInstalledEndpointHooksDetectsAlreadyInstalledBeaconHooks(t *testing.T) {
oldOpts := endpointOpts
oldConsoleUser := activeConsoleUser
oldRunAsUser := runHookRepairAsUser
t.Cleanup(func() {
endpointOpts = oldOpts
activeConsoleUser = oldConsoleUser
runHookRepairAsUser = oldRunAsUser
})

home := t.TempDir()
logPath := filepath.Join(t.TempDir(), "runtime.jsonl")
writeTestFile(t, filepath.Join(home, ".claude", "settings.json"), `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo keep"},{"type":"command","command":"BEACON_ENDPOINT_MODE=1 old-beacon-hooks --platform claude session-start"}]}]}}`)
writeTestFile(t, filepath.Join(home, ".codex", "hooks.json"), `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo keep"}]}]}}`)
writeTestFile(t, filepath.Join(home, ".factory", "settings.json"), `{"hooks":{"PostToolUse":[{"hooks":[{"type":"command","command":"BEACON_ENDPOINT_MODE=1 beacon-hooks --platform factory post-tool"}]}]}}`)
activeConsoleUser = func() (consoleUserInfo, bool, error) {
return consoleUserInfo{Username: "shad", HomeDir: home}, true, nil
}
var gotArgs []string
runHookRepairAsUser = func(info consoleUserInfo, args ...string) error {
gotArgs = append([]string(nil), args...)
return nil
}
endpointOpts.logPath = logPath
endpointOpts.hookHarnesses = ""
endpointOpts.hookLevel = "user"

result, err := repairInstalledEndpointHooks()
if err != nil {
t.Fatalf("repairInstalledEndpointHooks returned error: %v", err)
}
if strings.Join(result.Targets, ",") != "claude,factory" {
t.Fatalf("targets = %#v, want claude,factory", result.Targets)
}
wantArgs := []string{"endpoint", "hooks", "install", "--harness", "claude,factory", "--level", "user", "--log-path", logPath}
if strings.Join(gotArgs, "\x00") != strings.Join(wantArgs, "\x00") {
t.Fatalf("repair args = %#v, want %#v", gotArgs, wantArgs)
}
data, err := os.ReadFile(filepath.Join(home, ".claude", "settings.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "echo keep") {
t.Fatalf("detection should not modify hook file or drop non-Beacon hook: %s", string(data))
}
}

func TestRunEndpointInventoryWriteEventHonorsConfigContentOptIn(t *testing.T) {
old := endpointOpts
t.Cleanup(func() { endpointOpts = old })
Expand Down
21 changes: 21 additions & 0 deletions docs/guides/jamf-anthropic-openai-cursor-mdm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,22 @@ This policy:
- Installs Claude Code hooks for the console user.
- Starts `com.beacon.endpoint.s3-forwarder`.

## Optional: Enable Endpoint Self-Updates

Beacon package self-updates are off by default. To let Beacon apply future signed package updates after the Jamf install, add a Jamf Files and Processes payload or post-install policy command:

```bash title="Enable automatic package self-updates"
sudo /opt/beacon/bin/beacon endpoint update enable
```

For visibility without automatic package installation, enable check-only mode instead:

```bash title="Enable check-only update monitoring"
sudo /opt/beacon/bin/beacon endpoint update enable --check-only
```

Both commands require root. `auto` mode verifies release manifest checksums, Developer ID signature, and Gatekeeper install assessment before applying a signed package. `check-only` mode writes update status events to `/var/log/beacon-agent/system.jsonl` without downloading or applying packages.

## Step 3: Configure Cursor Hooks

Create a Jamf script named `Beacon - Configure Cursor Hooks`.
Expand All @@ -246,11 +262,16 @@ if [ -z "$CONSOLE_USER" ] || [ "$CONSOLE_USER" = "root" ] || [ "$CONSOLE_USER" =
fi

RUNTIME_DIR="$(dirname "$BEACON_HOOK_LOG_PATH")"
INVENTORY_LOG="$RUNTIME_DIR/inventory_state.jsonl"
INVENTORY_STATE="$RUNTIME_DIR/inventory-state.json"
mkdir -p "$RUNTIME_DIR"
touch "$BEACON_HOOK_LOG_PATH" "$BEACON_HOOK_LOG_PATH.lock"
touch "$INVENTORY_LOG" "$INVENTORY_LOG.lock" "$INVENTORY_STATE" "$INVENTORY_STATE.lock"
chown root:wheel "$RUNTIME_DIR" "$BEACON_HOOK_LOG_PATH" "$BEACON_HOOK_LOG_PATH.lock"
chown "$CONSOLE_USER":wheel "$INVENTORY_LOG" "$INVENTORY_LOG.lock" "$INVENTORY_STATE" "$INVENTORY_STATE.lock"
chmod 755 "$RUNTIME_DIR"
chmod 644 "$BEACON_HOOK_LOG_PATH" "$BEACON_HOOK_LOG_PATH.lock"
chmod 664 "$INVENTORY_LOG" "$INVENTORY_LOG.lock" "$INVENTORY_STATE" "$INVENTORY_STATE.lock"
chmod +a "$CONSOLE_USER allow list,search,add_file,readattr,writeattr" "$RUNTIME_DIR" || true
chmod +a "$CONSOLE_USER allow read,write,append,readattr,writeattr,readextattr,writeextattr" "$BEACON_HOOK_LOG_PATH" || true
chmod +a "$CONSOLE_USER allow read,write,append,readattr,writeattr,readextattr,writeextattr" "$BEACON_HOOK_LOG_PATH.lock" || true
Expand Down
16 changes: 16 additions & 0 deletions docs/guides/jamf-falcon-mdm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ The combined helper performs all endpoint and forwarding setup:

End users do not need to run any Beacon command or edit any local config.

## Optional: Enable Endpoint Self-Updates

Beacon package self-updates are off by default. To let Beacon apply future signed package updates after the Jamf install, add a Jamf Files and Processes payload or post-install policy command:

```bash title="Enable automatic package self-updates"
sudo /opt/beacon/bin/beacon endpoint update enable
```

For visibility without automatic package installation, enable check-only mode instead:

```bash title="Enable check-only update monitoring"
sudo /opt/beacon/bin/beacon endpoint update enable --check-only
```

Both commands require root. `auto` mode verifies release manifest checksums, Developer ID signature, and Gatekeeper install assessment before applying a signed package. `check-only` mode writes update status events to `/var/log/beacon-agent/system.jsonl` without downloading or applying packages.

## Manual Smoke Test

To simulate the Jamf policy on one Mac, install the Beacon package, then run the packaged helper manually.
Expand Down
16 changes: 16 additions & 0 deletions docs/guides/jamf-s3-mdm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,22 @@ AWS_SECRET_ACCESS_KEY="..."
/opt/beacon/jamf/claude/s3/repair-hooks-and-forwarder.sh
```

### 4. Optionally Enable Endpoint Self-Updates

Beacon package self-updates are off by default. To let Beacon apply future signed package updates after the Jamf install, add a Jamf Files and Processes payload or post-install policy command:

```bash title="Enable automatic package self-updates"
sudo /opt/beacon/bin/beacon endpoint update enable
```

For visibility without automatic package installation, enable check-only mode instead:

```bash title="Enable check-only update monitoring"
sudo /opt/beacon/bin/beacon endpoint update enable --check-only
```

Both commands require root. `auto` mode downloads newer compatible signed packages from the release manifest after checksum, Developer ID signature, and Gatekeeper verification. `check-only` mode writes update status events to `/var/log/beacon-agent/system.jsonl` without downloading or applying packages.

## What The Helper Does

The combined helper performs all endpoint and forwarding setup:
Expand Down
Loading
Loading