diff --git a/src/agent/risk-classifier.test.ts b/src/agent/risk-classifier.test.ts index 435c5e06..8b3c671d 100644 --- a/src/agent/risk-classifier.test.ts +++ b/src/agent/risk-classifier.test.ts @@ -54,6 +54,50 @@ describe('classifyRisk — bash high', () => { classifyRisk('bash', { command: 'curl https://install.sh |sh' }, ctx), ).toBe('high'); }); + + 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('systemctl enable → high', () => { + expect(classifyRisk('bash', { command: 'systemctl enable my-service' }, ctx)).toBe('high'); + }); + + it('systemctl start → high', () => { + expect(classifyRisk('bash', { command: 'systemctl start my.service' }, ctx)).toBe('high'); + }); + + it('systemctl daemon-reload → high', () => { + expect(classifyRisk('bash', { command: 'systemctl daemon-reload' }, ctx)).toBe('high'); + }); + + it('systemctl --user enable → high', () => { + expect( + classifyRisk('bash', { command: 'systemctl --user enable --now malicious.service' }, ctx), + ).toBe('high'); + }); + + it('systemctl --user start → high', () => { + expect( + classifyRisk('bash', { command: 'systemctl --user start malicious.service' }, ctx), + ).toBe('high'); + }); + + it('systemctl --user daemon-reload → high', () => { + expect(classifyRisk('bash', { command: 'systemctl --user daemon-reload' }, ctx)).toBe('high'); + }); + + it('launchctl list → NOT high', () => { + expect(classifyRisk('bash', { command: 'launchctl list' }, ctx)).not.toBe('high'); + }); }); // ---- bash medium-risk patterns ------------------------------------------- diff --git a/src/agent/risk-classifier.ts b/src/agent/risk-classifier.ts index ca149e8d..bc98c243 100644 --- a/src/agent/risk-classifier.ts +++ b/src/agent/risk-classifier.ts @@ -67,6 +67,22 @@ const BASH_HIGH: readonly string[] = [ '| bash', '|sh', '|bash', + 'launchctl load', + 'launchctl bootstrap', + 'launchctl submit', + 'launchctl start', + 'systemctl enable', + 'systemctl start', + 'systemctl daemon-reload', + // Invariant: `systemctl --user ` is the canonical form for user-service + // management on Linux (see src/service/systemd/install.ts:43-46). The bare + // forms above miss it because `--user` sits between `systemctl` and the verb. + // The substring table cannot express "optional flags" so we enumerate the + // common variant. Other global options (`--no-block`, `--quiet`, etc.) are + // not enumerated — the safe-destruct regex catches those via its quantifier. + 'systemctl --user enable', + 'systemctl --user start', + 'systemctl --user daemon-reload', ]; /** diff --git a/src/agent/safe-destruct-detect.test.ts b/src/agent/safe-destruct-detect.test.ts index a46bbcc9..239bec77 100644 --- a/src/agent/safe-destruct-detect.test.ts +++ b/src/agent/safe-destruct-detect.test.ts @@ -81,6 +81,16 @@ 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'], + ['launchctl load ~/Library/LaunchAgents/com.example.plist', 'launchctl-load'], + ['launchctl bootstrap gui/501 ~/Library/LaunchAgents/com.example.plist', 'launchctl-load'], + ['launchctl submit -l com.example -- /usr/bin/example', 'launchctl-load'], + ['launchctl start com.example', 'launchctl-load'], + ['systemctl enable my-service', 'systemctl-enable'], + ['systemctl start my.service', 'systemctl-enable'], + ['systemctl daemon-reload', 'systemctl-enable'], + ['systemctl --user enable --now malicious.service', 'systemctl-enable'], + ['systemctl --user start malicious.service', 'systemctl-enable'], + ['systemctl --user daemon-reload', 'systemctl-enable'], ])('BLOCK: flags %j → %s', (command, expectedId) => { expect(detectDestructiveCommands(command)).toContain(expectedId); }); @@ -104,6 +114,10 @@ describe('detectDestructiveCommands', () => { ['truncate -s 0 app.log'], // shell truncate, not SQL TRUNCATE TABLE ['echo "safe"'], [''], + ['launchctl list'], // read-only query + ['launchctl print gui/501'], // read-only query + ['systemctl status my-service'], // read-only query + ['systemctl is-enabled my-service'], // read-only query ])('does not flag benign %j', (command) => { expect(detectDestructiveCommands(command)).toEqual([]); }); @@ -163,6 +177,12 @@ describe('createSafeDestructDetect (two-tier hook)', () => { ["psql -c 'DROP DATABASE prod'", 'sql-drop-truncate'], ["mysql -e 'TRUNCATE TABLE users'", 'sql-drop-truncate'], ['terraform destroy -auto-approve', 'terraform-destroy'], + ['launchctl load ~/Library/LaunchAgents/com.example.plist', 'launchctl-load'], + ['launchctl bootstrap gui/501 ~/Library/LaunchAgents/com.example.plist', 'launchctl-load'], + ['systemctl enable my-service', 'systemctl-enable'], + ['systemctl daemon-reload', 'systemctl-enable'], + ['systemctl --user enable --now malicious.service', 'systemctl-enable'], + ['systemctl --user daemon-reload', 'systemctl-enable'], ])('BLOCK pattern %s returns block decision naming %s with injectContext', (command, expectedPatternId) => { const decision: HookDecision = hook(preCtx(command)); expect(decision.decision).toBe('block'); diff --git a/src/agent/safe-destruct-patterns.ts b/src/agent/safe-destruct-patterns.ts index 8f0a2c2e..0e7b3bd1 100644 --- a/src/agent/safe-destruct-patterns.ts +++ b/src/agent/safe-destruct-patterns.ts @@ -213,4 +213,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-load', + re: /\blaunchctl\s+(load|bootstrap|submit|start)\b/i, + tier: 'block', + blockReason: + 'safe-destruct: blocked [launchctl-load] — installs a persistent launchd service that survives reboots and session termination. Use `afk service install` via /service-setup instead. This hook cannot be self-bypassed: if the installation is genuinely intended, stop and ask the operator to run it.', + }, + { + id: 'systemctl-enable', + re: /\bsystemctl\s+(?:--?\w[\w-]*(?:[= ]\S+)?\s+)*(enable|start|daemon-reload)\b/i, + tier: 'block', + blockReason: + 'safe-destruct: blocked [systemctl-enable] — enables or starts a persistent systemd service that survives reboots and session termination. Use `afk service install` via /service-setup instead. This hook cannot be self-bypassed: if the installation is genuinely intended, stop and ask the operator to run it.', + }, ]; diff --git a/src/agent/tools/handlers/write-denylist.test.ts b/src/agent/tools/handlers/write-denylist.test.ts index 9ea08a5c..f2c18002 100644 --- a/src/agent/tools/handlers/write-denylist.test.ts +++ b/src/agent/tools/handlers/write-denylist.test.ts @@ -754,3 +754,32 @@ describe('write-denylist — tilde backslash expansion in AFK_WRITE_DENYLIST (PR expect(expanded?.startsWith(homedir())).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// PR #1279 — OS service registration directories in BUILTIN_WRITE_DENYLIST +// --------------------------------------------------------------------------- + +describe('write-denylist — OS service registration directories (PR #1279)', () => { + it('macOS: LaunchAgents/LaunchDaemons present only on darwin', () => { + if (process.platform === 'darwin') { + expect(BUILTIN_WRITE_DENYLIST.some((p) => p.includes('LaunchAgents'))).toBe(true); + expect(BUILTIN_WRITE_DENYLIST.some((p) => p.includes('LaunchDaemons'))).toBe(true); + expect(BUILTIN_WRITE_DENYLIST).toContain(`${homedir()}/Library/LaunchAgents`); + expect(BUILTIN_WRITE_DENYLIST).toContain(`${homedir()}/Library/LaunchDaemons`); + expect(BUILTIN_WRITE_DENYLIST).toContain('/Library/LaunchAgents'); + expect(BUILTIN_WRITE_DENYLIST).toContain('/Library/LaunchDaemons'); + } else { + expect(BUILTIN_WRITE_DENYLIST.some((p) => p.includes('LaunchAgents'))).toBe(false); + expect(BUILTIN_WRITE_DENYLIST.some((p) => p.includes('LaunchDaemons'))).toBe(false); + } + }); + + it('linux: systemd/user present only on linux', () => { + if (process.platform === 'linux') { + expect(BUILTIN_WRITE_DENYLIST).toContain(`${homedir()}/.config/systemd/user`); + expect(BUILTIN_WRITE_DENYLIST).toContain('/etc/systemd/system'); + } else { + expect(BUILTIN_WRITE_DENYLIST.some((p) => p.includes('systemd') && !p.includes('/etc'))).toBe(false); + } + }); +}); diff --git a/src/agent/tools/handlers/write-denylist.ts b/src/agent/tools/handlers/write-denylist.ts index 6f366af3..8c35ab79 100644 --- a/src/agent/tools/handlers/write-denylist.ts +++ b/src/agent/tools/handlers/write-denylist.ts @@ -61,6 +61,21 @@ export const BUILTIN_WRITE_DENYLIST: readonly string[] = [ `${env.USERPROFILE}\\.gnupg`, ] : []), + // S5 — OS service registration directories (#1279) + ...(process.platform === 'darwin' + ? [ + `${homedir()}/Library/LaunchAgents`, + `${homedir()}/Library/LaunchDaemons`, + '/Library/LaunchAgents', + '/Library/LaunchDaemons', + ] + : []), + ...(process.platform === 'linux' + ? [ + `${homedir()}/.config/systemd/user`, + '/etc/systemd/system', + ] + : []), ]; /** diff --git a/src/agent/tools/hooks/bash-restriction-hook.test.ts b/src/agent/tools/hooks/bash-restriction-hook.test.ts index dfb67b08..09d0b1e7 100644 --- a/src/agent/tools/hooks/bash-restriction-hook.test.ts +++ b/src/agent/tools/hooks/bash-restriction-hook.test.ts @@ -428,6 +428,18 @@ describe('SENSITIVE_PATH_SIGNAL stays in sync with the built-in sensitive roots' const uncovered = allCandidates.filter((c) => !SENSITIVE_PATH_SIGNAL.test(c)); expect(uncovered).toEqual([]); }); + + it('includes Library/LaunchAgents in builtinBashSensitiveRoots', () => { + expect(allCandidates.some((c) => c.includes('LaunchAgents'))).toBe(true); + }); + + it('includes Library/LaunchDaemons in builtinBashSensitiveRoots', () => { + expect(allCandidates.some((c) => c.includes('LaunchDaemons'))).toBe(true); + }); + + it('includes .config/systemd/user in builtinBashSensitiveRoots', () => { + expect(allCandidates.some((c) => c.includes('systemd'))).toBe(true); + }); }); describe('deriveRestrictedSubstrings — no coverage regression from sharing the read denylist', () => { diff --git a/src/agent/tools/hooks/bash-restriction-hook.ts b/src/agent/tools/hooks/bash-restriction-hook.ts index 8252c16c..9b5efc6e 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|\.config\/systemd\/user\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|Library\/LaunchAgents\b|Library\/LaunchDaemons\b|\/etc\/shadow\b|\/etc\/sudoers\b|master\.passwd\b/i; export interface BashRestrictionHookOptions { /** @@ -481,8 +481,11 @@ export function builtinBashSensitiveRoots(): readonly string[] { const home = homedir(); return withEtcAliases([ path.join(home, 'Library', 'Application Support'), + path.join(home, 'Library', 'LaunchAgents'), + path.join(home, 'Library', 'LaunchDaemons'), path.join(home, '.password-store'), path.join(home, '.config', 'gh'), + path.join(home, '.config', 'systemd', 'user'), ...BUILTIN_READ_DENYLIST, ]); }