From 9054ff9502f256c3ab9e4a0e2bfa7c7be820c649 Mon Sep 17 00:00:00 2001 From: Griffin Long Date: Thu, 27 Aug 2026 13:31:40 -0400 Subject: [PATCH 1/4] fix(safety): block direct launchctl/systemctl service registration (#1279) Add launchctl load/bootstrap/submit/start and systemctl enable/start/daemon-reload to all four safety guard layers: - risk-classifier: BASH_HIGH tier (triggers AFK approval prompt) - safe-destruct-patterns: BLOCK tier (hard block with redirect to /service-setup) - write-denylist: deny writes to ~/Library/LaunchAgents, LaunchDaemons, ~/.config/systemd - bash-restriction-hook: sensitive roots + SENSITIVE_PATH_SIGNAL regex Closes #1279 --- src/agent/risk-classifier.ts | 7 +++++++ src/agent/safe-destruct-patterns.ts | 14 ++++++++++++++ src/agent/tools/handlers/write-denylist.ts | 7 +++++++ src/agent/tools/hooks/bash-restriction-hook.ts | 7 ++++++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/agent/risk-classifier.ts b/src/agent/risk-classifier.ts index 3c0fba5d..788d578e 100644 --- a/src/agent/risk-classifier.ts +++ b/src/agent/risk-classifier.ts @@ -95,6 +95,13 @@ const BASH_HIGH: readonly string[] = [ 'curl -F', 'wget --post-data', 'wget --post-file', + 'launchctl load', + 'launchctl bootstrap', + 'launchctl submit', + 'launchctl start', + 'systemctl enable', + 'systemctl start', + 'systemctl daemon-reload', ]; /** diff --git a/src/agent/safe-destruct-patterns.ts b/src/agent/safe-destruct-patterns.ts index 96d86ab1..9e292777 100644 --- a/src/agent/safe-destruct-patterns.ts +++ b/src/agent/safe-destruct-patterns.ts @@ -240,4 +240,18 @@ export const DESTRUCTIVE_PATTERNS: readonly DestructivePattern[] = [ blockReason: 'safe-destruct: blocked [terraform-destroy] — tears down live external infrastructure irrecoverably (new apply creates new resources, not the same ones); run "terraform plan -destroy" to preview. This hook cannot be self-bypassed: if the destruction is genuinely intended, stop and ask the operator to run it.', }, + { + id: 'launchctl-service-register', + re: /\blaunchctl\s+(load|bootstrap|submit|start)\b/i, + tier: 'block', + blockReason: + 'safe-destruct: blocked [launchctl-service-register] — installs a persistent launchd service that survives reboots and session boundaries; use `afk service install` via /service-setup instead. This hook cannot be self-bypassed: if the destruction is genuinely intended, stop and ask the operator to run it.', + }, + { + id: 'systemctl-service-enable', + re: /\bsystemctl\s+(enable|start|daemon-reload)\b/i, + tier: 'block', + blockReason: + 'safe-destruct: blocked [systemctl-service-enable] — enables/starts a systemd unit that persists across sessions and reboots; use `afk service install` via /service-setup instead. This hook cannot be self-bypassed: if the destruction is genuinely intended, stop and ask the operator to run it.', + }, ]; diff --git a/src/agent/tools/handlers/write-denylist.ts b/src/agent/tools/handlers/write-denylist.ts index 6f366af3..7883708a 100644 --- a/src/agent/tools/handlers/write-denylist.ts +++ b/src/agent/tools/handlers/write-denylist.ts @@ -47,6 +47,13 @@ export const BUILTIN_WRITE_DENYLIST: readonly string[] = [ // S4: npm publish tokens and Docker registry credentials. `${homedir()}/.npmrc`, `${homedir()}/.docker/config.json`, + // S4: LaunchAgent/LaunchDaemon plist dirs — service registration without approval. + `${homedir()}/Library/LaunchAgents`, + `${homedir()}/Library/LaunchDaemons`, + '/Library/LaunchAgents', + '/Library/LaunchDaemons', + // S4: systemd unit dirs — service registration without approval. + `${homedir()}/.config/systemd`, // S4-win32: Windows credential/config trees. Gated on both process.platform // and the env var: on POSIX, USERPROFILE/APPDATA may be set in CI/Docker // but the backslash paths would resolve incorrectly — the platform guard diff --git a/src/agent/tools/hooks/bash-restriction-hook.ts b/src/agent/tools/hooks/bash-restriction-hook.ts index 8252c16c..d66afbc1 100644 --- a/src/agent/tools/hooks/bash-restriction-hook.ts +++ b/src/agent/tools/hooks/bash-restriction-hook.ts @@ -129,7 +129,7 @@ const INTERPRETER_DENYLIST = * {@link scrubAllowlistedRefs}, so it needs no exception here. */ export const SENSITIVE_PATH_SIGNAL = - /\.ssh\b|\bid_rsa\b|\bid_ed25519\b|\.gnupg\b|\.aws\b|\.config\/gh\b|\.config\/gcloud\b|\.netrc\b|\.password-store\b|\.afk\/config\b|\.npmrc\b|\.docker\/config\.json\b|\.git-credentials\b|\.kube\/config\b|Library\/Application Support\b|\/etc\/shadow\b|\/etc\/sudoers\b|master\.passwd\b/i; + /\.ssh\b|\bid_rsa\b|\bid_ed25519\b|\.gnupg\b|\.aws\b|\.config\/gh\b|\.config\/gcloud\b|\.netrc\b|\.password-store\b|\.afk\/config\b|\.npmrc\b|\.docker\/config\.json\b|\.git-credentials\b|\.kube\/config\b|Library\/Application Support\b|\/etc\/shadow\b|\/etc\/sudoers\b|master\.passwd\b|Library\/LaunchAgents\b|Library\/LaunchDaemons\b|\.config\/systemd\b/i; export interface BashRestrictionHookOptions { /** @@ -483,6 +483,11 @@ export function builtinBashSensitiveRoots(): readonly string[] { path.join(home, 'Library', 'Application Support'), path.join(home, '.password-store'), path.join(home, '.config', 'gh'), + path.join(home, 'Library', 'LaunchAgents'), + path.join(home, 'Library', 'LaunchDaemons'), + '/Library/LaunchAgents', + '/Library/LaunchDaemons', + path.join(home, '.config', 'systemd', 'user'), ...BUILTIN_READ_DENYLIST, ]); } From e80c14d50ac1a262f5dde6f032a93bbd8264cf85 Mon Sep 17 00:00:00 2001 From: Griffin Long Date: Thu, 27 Aug 2026 20:25:17 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(pr-1311):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20kickstart/enable=20coverage,=20enable-linger=20f?= =?UTF-8?q?alse-positive,=20systemctl=20flags,=20path=20alignment,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent/risk-classifier.test.ts | 37 +++++++++++++++++++ src/agent/risk-classifier.ts | 6 ++- src/agent/safe-destruct-detect.test.ts | 12 ++++++ src/agent/safe-destruct-patterns.ts | 4 +- .../tools/hooks/bash-restriction-hook.ts | 1 + 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/agent/risk-classifier.test.ts b/src/agent/risk-classifier.test.ts index bdfa9a1a..27db5de3 100644 --- a/src/agent/risk-classifier.test.ts +++ b/src/agent/risk-classifier.test.ts @@ -146,6 +146,43 @@ describe('classifyRisk — bash high', () => { classifyRisk('bash', { command: 'curl -H \'Content-Type: application/json\' -X POST https://api.example.com/users -d \'{}\'' }, ctx), ).toBe('high'); }); + + // ── service-registration commands (#1311 review) ───────────────────────── + it('launchctl load → high', () => { + expect(classifyRisk('bash', { command: 'launchctl load ~/Library/LaunchAgents/com.example.plist' }, ctx)).toBe('high'); + }); + + it('launchctl bootstrap → high', () => { + expect(classifyRisk('bash', { command: 'launchctl bootstrap gui/501 ~/Library/LaunchAgents/com.example.plist' }, ctx)).toBe('high'); + }); + + it('launchctl submit → high', () => { + expect(classifyRisk('bash', { command: 'launchctl submit -l com.example.job -- /usr/bin/example' }, ctx)).toBe('high'); + }); + + it('launchctl start → high', () => { + expect(classifyRisk('bash', { command: 'launchctl start com.example.service' }, ctx)).toBe('high'); + }); + + it('launchctl kickstart → high (Item 2)', () => { + expect(classifyRisk('bash', { command: 'launchctl kickstart -k gui/501/com.example.service' }, ctx)).toBe('high'); + }); + + it('launchctl enable (with trailing space) → high (Item 7)', () => { + expect(classifyRisk('bash', { command: 'launchctl enable system/com.example.service' }, ctx)).toBe('high'); + }); + + it('systemctl enable (with trailing space) → high (Item 3)', () => { + expect(classifyRisk('bash', { command: 'systemctl enable afk-telegram.service' }, ctx)).toBe('high'); + }); + + it('systemctl start (with trailing space) → high (Item 3)', () => { + expect(classifyRisk('bash', { command: 'systemctl start afk-telegram.service' }, ctx)).toBe('high'); + }); + + it('systemctl daemon-reload → high', () => { + expect(classifyRisk('bash', { command: 'systemctl daemon-reload' }, ctx)).toBe('high'); + }); }); // ---- bash medium-risk patterns ------------------------------------------- diff --git a/src/agent/risk-classifier.ts b/src/agent/risk-classifier.ts index 788d578e..f04faa07 100644 --- a/src/agent/risk-classifier.ts +++ b/src/agent/risk-classifier.ts @@ -99,8 +99,10 @@ const BASH_HIGH: readonly string[] = [ 'launchctl bootstrap', 'launchctl submit', 'launchctl start', - 'systemctl enable', - 'systemctl start', + 'launchctl kickstart', + 'launchctl enable ', + 'systemctl enable ', + 'systemctl start ', 'systemctl daemon-reload', ]; diff --git a/src/agent/safe-destruct-detect.test.ts b/src/agent/safe-destruct-detect.test.ts index 9b252123..2a2e5a43 100644 --- a/src/agent/safe-destruct-detect.test.ts +++ b/src/agent/safe-destruct-detect.test.ts @@ -98,10 +98,22 @@ describe('detectDestructiveCommands', () => { ["psql -c 'DROP SCHEMA public'", 'sql-drop-truncate'], ["psql -c 'DROP INDEX idx_name'", 'sql-drop-truncate'], ['terraform destroy -auto-approve', 'terraform-destroy'], + // service-registration patterns (#1311 review) + ['launchctl bootstrap gui/501 /path/to/com.example.plist', 'launchctl-service-register'], + ['launchctl kickstart -k gui/501/com.example.service', 'launchctl-service-register'], + ['systemctl enable afk-telegram.service', 'systemctl-service-enable'], + ['systemctl --user enable --now afk-telegram.service', 'systemctl-service-enable'], + ['systemctl daemon-reload', 'systemctl-service-enable'], ])('BLOCK: flags %j → %s', (command, expectedId) => { expect(detectDestructiveCommands(command)).toContain(expectedId); }); + // ── enable-linger false-positive fix (#1311 Item 3) ────────────────────── + it('systemctl enable-linger root → does NOT match systemctl-service-enable', () => { + const ids = detectDestructiveCommands('systemctl enable-linger root'); + expect(ids).not.toContain('systemctl-service-enable'); + }); + // ── benign commands — must match nothing ──────────────────────────────────── it.each([ ['rm file.txt'], // no recursive/force diff --git a/src/agent/safe-destruct-patterns.ts b/src/agent/safe-destruct-patterns.ts index 9e292777..f49e5d18 100644 --- a/src/agent/safe-destruct-patterns.ts +++ b/src/agent/safe-destruct-patterns.ts @@ -242,14 +242,14 @@ export const DESTRUCTIVE_PATTERNS: readonly DestructivePattern[] = [ }, { id: 'launchctl-service-register', - re: /\blaunchctl\s+(load|bootstrap|submit|start)\b/i, + re: /\blaunchctl\s+(?:--?\w[\w-]*\s+)*(load|bootstrap|submit|start|kickstart|enable)(?:\s|$)/i, tier: 'block', blockReason: 'safe-destruct: blocked [launchctl-service-register] — installs a persistent launchd service that survives reboots and session boundaries; use `afk service install` via /service-setup instead. This hook cannot be self-bypassed: if the destruction is genuinely intended, stop and ask the operator to run it.', }, { id: 'systemctl-service-enable', - re: /\bsystemctl\s+(enable|start|daemon-reload)\b/i, + re: /\bsystemctl\s+(?:--?\w[\w-]*\s+)*(enable|start|daemon-reload)(?:\s|$)/i, tier: 'block', blockReason: 'safe-destruct: blocked [systemctl-service-enable] — enables/starts a systemd unit that persists across sessions and reboots; use `afk service install` via /service-setup instead. This hook cannot be self-bypassed: if the destruction is genuinely intended, stop and ask the operator to run it.', diff --git a/src/agent/tools/hooks/bash-restriction-hook.ts b/src/agent/tools/hooks/bash-restriction-hook.ts index d66afbc1..4a16c3fe 100644 --- a/src/agent/tools/hooks/bash-restriction-hook.ts +++ b/src/agent/tools/hooks/bash-restriction-hook.ts @@ -487,6 +487,7 @@ export function builtinBashSensitiveRoots(): readonly string[] { path.join(home, 'Library', 'LaunchDaemons'), '/Library/LaunchAgents', '/Library/LaunchDaemons', + path.join(home, '.config', 'systemd'), path.join(home, '.config', 'systemd', 'user'), ...BUILTIN_READ_DENYLIST, ]); From 77314aa322a0a658874bee8c7e997c27814758d2 Mon Sep 17 00:00:00 2001 From: Griffin Long Date: Thu, 27 Aug 2026 20:33:55 -0400 Subject: [PATCH 3/4] fix(pr-1311): handle --flag=value in service-registration regex prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the same flag-prefix pattern used by git-reset-hard and git-clean-force — (?:(?:-{2}[\w-]+(?:=\S+)?|-[A-Za-z]…)\s+)* — so that systemctl --type=service enable and similar --key=val forms are caught by the safe-destruct BLOCK regex. Adds a test case for the --type=service form. --- src/agent/safe-destruct-detect.test.ts | 1 + src/agent/safe-destruct-patterns.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent/safe-destruct-detect.test.ts b/src/agent/safe-destruct-detect.test.ts index 2a2e5a43..49e0a566 100644 --- a/src/agent/safe-destruct-detect.test.ts +++ b/src/agent/safe-destruct-detect.test.ts @@ -103,6 +103,7 @@ describe('detectDestructiveCommands', () => { ['launchctl kickstart -k gui/501/com.example.service', 'launchctl-service-register'], ['systemctl enable afk-telegram.service', 'systemctl-service-enable'], ['systemctl --user enable --now afk-telegram.service', 'systemctl-service-enable'], + ['systemctl --type=service enable afk-telegram.service', 'systemctl-service-enable'], ['systemctl daemon-reload', 'systemctl-service-enable'], ])('BLOCK: flags %j → %s', (command, expectedId) => { expect(detectDestructiveCommands(command)).toContain(expectedId); diff --git a/src/agent/safe-destruct-patterns.ts b/src/agent/safe-destruct-patterns.ts index f49e5d18..938dfdc7 100644 --- a/src/agent/safe-destruct-patterns.ts +++ b/src/agent/safe-destruct-patterns.ts @@ -242,14 +242,14 @@ export const DESTRUCTIVE_PATTERNS: readonly DestructivePattern[] = [ }, { id: 'launchctl-service-register', - re: /\blaunchctl\s+(?:--?\w[\w-]*\s+)*(load|bootstrap|submit|start|kickstart|enable)(?:\s|$)/i, + re: /\blaunchctl\s+(?:(?:-{2}[\w-]+(?:=\S+)?|-[A-Za-z](?:\s+\S+)?)\s+)*(load|bootstrap|submit|start|kickstart|enable)(?:\s|$)/i, tier: 'block', blockReason: 'safe-destruct: blocked [launchctl-service-register] — installs a persistent launchd service that survives reboots and session boundaries; use `afk service install` via /service-setup instead. This hook cannot be self-bypassed: if the destruction is genuinely intended, stop and ask the operator to run it.', }, { id: 'systemctl-service-enable', - re: /\bsystemctl\s+(?:--?\w[\w-]*\s+)*(enable|start|daemon-reload)(?:\s|$)/i, + re: /\bsystemctl\s+(?:(?:-{2}[\w-]+(?:=\S+)?|-[A-Za-z](?:\s+\S+)?)\s+)*(enable|start|daemon-reload)(?:\s|$)/i, tier: 'block', blockReason: 'safe-destruct: blocked [systemctl-service-enable] — enables/starts a systemd unit that persists across sessions and reboots; use `afk service install` via /service-setup instead. This hook cannot be self-bypassed: if the destruction is genuinely intended, stop and ask the operator to run it.', From 1d673d8c07af21a8cf915738abbcbd33a05affb9 Mon Sep 17 00:00:00 2001 From: Griffin Long Date: Thu, 27 Aug 2026 20:47:50 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(pr-1311):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20test=20coverage=20gaps=20and=20trailing-space=20con?= =?UTF-8?q?sistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 4 missing launchctl verb tests (load, start, submit, enable) to safe-destruct-detect BLOCK table; incident-vector `launchctl load` was untested at Layer 2 - Add trailing space to `'launchctl start '` in BASH_HIGH for consistency with `'launchctl enable '` and `'systemctl start '` - Add false-positive negative tests for `enable-linger` at Layer 1 (risk-classifier) matching the existing Layer 2 coverage --- src/agent/risk-classifier.test.ts | 9 +++++++++ src/agent/risk-classifier.ts | 2 +- src/agent/safe-destruct-detect.test.ts | 4 ++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/agent/risk-classifier.test.ts b/src/agent/risk-classifier.test.ts index 27db5de3..449ec3db 100644 --- a/src/agent/risk-classifier.test.ts +++ b/src/agent/risk-classifier.test.ts @@ -183,6 +183,15 @@ describe('classifyRisk — bash high', () => { it('systemctl daemon-reload → high', () => { expect(classifyRisk('bash', { command: 'systemctl daemon-reload' }, ctx)).toBe('high'); }); + + // ── false-positive guards (trailing-space convention) ───────────────────── + it('launchctl enable-linger → NOT high (trailing space guards enable)', () => { + expect(classifyRisk('bash', { command: 'launchctl enable-linger root' }, ctx)).not.toBe('high'); + }); + + it('systemctl enable-linger → NOT high (trailing space guards enable)', () => { + expect(classifyRisk('bash', { command: 'systemctl enable-linger root' }, ctx)).not.toBe('high'); + }); }); // ---- bash medium-risk patterns ------------------------------------------- diff --git a/src/agent/risk-classifier.ts b/src/agent/risk-classifier.ts index f04faa07..38a9e620 100644 --- a/src/agent/risk-classifier.ts +++ b/src/agent/risk-classifier.ts @@ -98,7 +98,7 @@ const BASH_HIGH: readonly string[] = [ 'launchctl load', 'launchctl bootstrap', 'launchctl submit', - 'launchctl start', + 'launchctl start ', 'launchctl kickstart', 'launchctl enable ', 'systemctl enable ', diff --git a/src/agent/safe-destruct-detect.test.ts b/src/agent/safe-destruct-detect.test.ts index 49e0a566..f542fdc6 100644 --- a/src/agent/safe-destruct-detect.test.ts +++ b/src/agent/safe-destruct-detect.test.ts @@ -99,8 +99,12 @@ describe('detectDestructiveCommands', () => { ["psql -c 'DROP INDEX idx_name'", 'sql-drop-truncate'], ['terraform destroy -auto-approve', 'terraform-destroy'], // service-registration patterns (#1311 review) + ['launchctl load /Library/LaunchDaemons/com.example.plist', 'launchctl-service-register'], ['launchctl bootstrap gui/501 /path/to/com.example.plist', 'launchctl-service-register'], + ['launchctl submit -l com.example.service -- /usr/local/bin/mybin', 'launchctl-service-register'], + ['launchctl start com.example.service', 'launchctl-service-register'], ['launchctl kickstart -k gui/501/com.example.service', 'launchctl-service-register'], + ['launchctl enable system/com.example.service', 'launchctl-service-register'], ['systemctl enable afk-telegram.service', 'systemctl-service-enable'], ['systemctl --user enable --now afk-telegram.service', 'systemctl-service-enable'], ['systemctl --type=service enable afk-telegram.service', 'systemctl-service-enable'],