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
46 changes: 46 additions & 0 deletions src/agent/risk-classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions src/agent/risk-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

/**
Expand Down
17 changes: 17 additions & 0 deletions src/agent/safe-destruct-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/agent/safe-destruct-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
];
7 changes: 7 additions & 0 deletions src/agent/tools/handlers/write-denylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/agent/tools/hooks/bash-restriction-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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,
]);
}
Expand Down
Loading