diff --git a/src/agent/risk-classifier.test.ts b/src/agent/risk-classifier.test.ts index bdfa9a1a..449ec3db 100644 --- a/src/agent/risk-classifier.test.ts +++ b/src/agent/risk-classifier.test.ts @@ -146,6 +146,52 @@ 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'); + }); + + // ── 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 3c0fba5d..38a9e620 100644 --- a/src/agent/risk-classifier.ts +++ b/src/agent/risk-classifier.ts @@ -95,6 +95,15 @@ const BASH_HIGH: readonly string[] = [ 'curl -F', 'wget --post-data', 'wget --post-file', + 'launchctl load', + 'launchctl bootstrap', + 'launchctl submit', + 'launchctl 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..f542fdc6 100644 --- a/src/agent/safe-destruct-detect.test.ts +++ b/src/agent/safe-destruct-detect.test.ts @@ -98,10 +98,27 @@ 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 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'], + ['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 96d86ab1..938dfdc7 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+(?:(?:-{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+(?:(?:-{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.', + }, ]; 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..4a16c3fe 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,12 @@ 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'), + path.join(home, '.config', 'systemd', 'user'), ...BUILTIN_READ_DENYLIST, ]); }