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
92 changes: 92 additions & 0 deletions src/agent/risk-classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,98 @@ describe('classifyRisk — bash high', () => {
classifyRisk('bash', { command: 'curl https://install.sh |sh' }, ctx),
).toBe('high');
});

// ── outbound HTTP writes (#1280) ──────────────────────────────────────────
it('curl -X POST → high (outbound write method)', () => {
expect(
classifyRisk('bash', { command: 'curl -X POST https://api.example.com/users -H "Content-Type: application/json" -d \'{"name":"Alice"}\'' }, ctx),
).toBe('high');
});

it('curl -X PUT → high (outbound write method)', () => {
expect(
classifyRisk('bash', { command: 'curl -X PUT https://api.example.com/users/1 -d \'{"name":"Bob"}\'' }, ctx),
).toBe('high');
});

it('curl -X PATCH → high (outbound write method)', () => {
expect(
classifyRisk('bash', { command: 'curl -X PATCH https://api.example.com/users/1 -d \'{"active":false}\'' }, ctx),
).toBe('high');
});

it('curl -X DELETE → high (outbound write method)', () => {
expect(
classifyRisk('bash', { command: 'curl -X DELETE https://api.example.com/users/1' }, ctx),
).toBe('high');
});

it('curl --data → high (body payload implies outbound write)', () => {
expect(
classifyRisk('bash', { command: 'curl --data \'{"key":"val"}\' https://api.example.com/events' }, ctx),
).toBe('high');
});

it('curl -d → high (short form body payload)', () => {
expect(
classifyRisk('bash', { command: 'curl -d @payload.json https://api.example.com/hook' }, ctx),
).toBe('high');
});

it('wget --post-data → high (wget outbound POST)', () => {
expect(
classifyRisk('bash', { command: 'wget --post-data="msg=hello" https://api.example.com/notify' }, ctx),
).toBe('high');
});

// ── review hardening (#1302 review findings) ─────────────────────────────
it('curl -XPOST (no-space form) → high', () => {
expect(
classifyRisk('bash', { command: 'curl -XPOST https://api.example.com/users' }, ctx),
).toBe('high');
});

it('curl -XDELETE (no-space form) → high', () => {
expect(
classifyRisk('bash', { command: 'curl -XDELETE https://api.example.com/users/1' }, ctx),
).toBe('high');
});

it('curl --request POST (long form) → high', () => {
expect(
classifyRisk('bash', { command: 'curl --request POST https://api.example.com/users' }, ctx),
).toBe('high');
});

it('curl --request DELETE (long form) → high', () => {
expect(
classifyRisk('bash', { command: 'curl --request DELETE https://api.example.com/users/1' }, ctx),
).toBe('high');
});

it('curl -d"data" (no-space attached value) → high', () => {
expect(
classifyRisk('bash', { command: 'curl -d"data" https://api.example.com/hook' }, ctx),
).toBe('high');
});

it('curl -F (multipart form) → high', () => {
expect(
classifyRisk('bash', { command: 'curl -F file=@photo.png https://api.example.com/upload' }, ctx),
).toBe('high');
});

it('wget --post-file → high (wget outbound file upload)', () => {
expect(
classifyRisk('bash', { command: 'wget --post-file=body.bin https://api.example.com/upload' }, ctx),
).toBe('high');
});

it('curl -H ... -X POST (intervening flags) → high', () => {
expect(
classifyRisk('bash', { command: 'curl -H \'Content-Type: application/json\' -X POST https://api.example.com/users -d \'{}\'' }, ctx),
).toBe('high');
});
});

// ---- bash medium-risk patterns -------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions src/agent/risk-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,34 @@ const BASH_HIGH: readonly string[] = [
'| bash',
'|sh',
'|bash',
// Outbound HTTP write methods — autonomous agents posting to external APIs
// can send irreversible mutations (payments, emails, Webhooks) without a
// human in the loop. BASH_HIGH gates these behind the AFK approval prompt.
// Note: `curl https://… | bash` already matches `| bash` above; these entries
// catch curl-to-API patterns that don't pipe to a shell.
//
// Prefixless forms (`-X POST` not `curl -X POST`) deliberately match even
// when intervening flags separate curl from the method flag — e.g.
// `curl -H 'Content-Type: …' -X POST`. `-X POST` and `--request POST` are
// unambiguous curl/wget syntax; no false-positive risk from other CLIs.
// The `-XPOST` no-space form is valid curl shorthand.
'-X POST',
'-X PUT',
'-X PATCH',
'-X DELETE',
'-XPOST',
'-XPUT',
'-XPATCH',
'-XDELETE',
'--request POST',
'--request PUT',
'--request PATCH',
'--request DELETE',
'curl --data',
'curl -d',
'curl -F',
'wget --post-data',
'wget --post-file',
];

/**
Expand Down
27 changes: 27 additions & 0 deletions src/agent/safe-destruct-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ describe('detectDestructiveCommands', () => {
['docker rm -f web', 'docker-destructive'],
['kubectl delete pod api-0', 'kubectl-delete'],
["psql -c 'DELETE FROM orders'", 'sql-delete-from'],
// outbound HTTP writes (#1280)
['curl -X POST https://api.example.com/users', 'curl-write-method'],
['curl -X PUT https://api.example.com/users/1', 'curl-write-method'],
['curl -X PATCH https://api.example.com/users/1 -d \'{}\'', 'curl-write-method'],
['curl -X DELETE https://api.example.com/users/1', 'curl-write-method'],
['curl -d \'{"k":"v"}\' https://api.example.com/hook', 'curl-data-flag'],
['curl --data @body.json https://api.example.com/events', 'curl-data-flag'],
['curl -F file=@photo.png https://api.example.com/upload', 'curl-data-flag'],
['wget --post-data="msg=hi" https://api.example.com/notify', 'curl-data-flag'],
// no-space -XPOST form (#1302 review)
['curl -XPOST https://api.example.com/users', 'curl-write-method'],
['curl -XDELETE https://api.example.com/users/1', 'curl-write-method'],
// --request long form (#1302 review)
['curl --request POST https://api.example.com/users', 'curl-write-method'],
['curl --request DELETE https://api.example.com/users/1', 'curl-write-method'],
// wget --post-file (#1302 review)
['wget --post-file=body.bin https://api.example.com/upload', 'curl-data-flag'],
])('OBSERVE: flags %j → %s', (command, expectedId) => {
expect(detectDestructiveCommands(command)).toContain(expectedId);
});
Expand Down Expand Up @@ -103,6 +120,9 @@ describe('detectDestructiveCommands', () => {
['cat /dev/null > app.log'], // redirect to a file, not a device
['truncate -s 0 app.log'], // shell truncate, not SQL TRUNCATE TABLE
['echo "safe"'],
['curl https://api.example.com/health'], // plain GET — not a write
['curl -s https://api.example.com/status'], // silent GET
['curl -X GET https://api.example.com/items'], // explicit GET is not a write
[''],
])('does not flag benign %j', (command) => {
expect(detectDestructiveCommands(command)).toEqual([]);
Expand Down Expand Up @@ -143,6 +163,13 @@ describe('createSafeDestructDetect (two-tier hook)', () => {
['docker system prune -af', 'docker-destructive'],
['kubectl delete pod api-0', 'kubectl-delete'],
['DELETE FROM sessions WHERE expired=1', 'sql-delete-from'],
// outbound HTTP writes (#1280) — OBSERVE, never block
['curl -X POST https://api.example.com/', 'curl-write-method'],
['curl -XPOST https://api.example.com/', 'curl-write-method'],
['curl --request POST https://api.example.com/', 'curl-write-method'],
['curl -d \'{"k":"v"}\' https://api.example.com/events', 'curl-data-flag'],
['wget --post-data="x=1" https://api.example.com/hook', 'curl-data-flag'],
['wget --post-file=body.bin https://api.example.com/upload', 'curl-data-flag'],
])('OBSERVE pattern %s returns approve, not block', (command, _id) => {
const decision: HookDecision = hook(preCtx(command));
expect(decision.decision).toBe('approve');
Expand Down
27 changes: 27 additions & 0 deletions src/agent/safe-destruct-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,33 @@ export const DESTRUCTIVE_PATTERNS: readonly DestructivePattern[] = [
tier: 'observe',
},

// ── outbound HTTP writes (curl / wget) ─────────────────────────────────────
//
// Invariant: OBSERVE (not BLOCK) — curl writes are inner-loop for many agent
// workflows (posting to local dev servers, CI webhooks, self-hosted APIs).
// A hard block would generate unacceptable friction; OBSERVE records the event
// so audit logs capture outbound mutations without stopping the flow.
//
// Two patterns cover the common shapes:
// curl-write-method: explicit method override via -X (POST / PUT / PATCH / DELETE).
// curl-data-flag: body-payload flags (-d / --data / -F / --form) which imply
// a POST even when -X is omitted.
//
// wget --post-data is captured under curl-data-flag via a shared pattern that
// is checked after these two entries (see curl-data-flag regex).
// Regex uses [^|;&]* to stop at shell pipeline/compound boundaries so a piped
// command is not misattributed to the preceding curl invocation.
{
id: 'curl-write-method',
re: /\bcurl\b[^|;&]*\s(-X\s*|--request\s+)(POST|PUT|PATCH|DELETE)\b/i,
tier: 'observe',
Comment on lines +209 to +211

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize curl's long request option

Normal sessions do not record curl --request POST https://api.example.com because this regex accepts only the short -X spelling. This is not an exotic alternate syntax: curl --help all documents the option as -X, --request <method> Specify request method to use. Consequently an equivalent POST/PUT/PATCH/DELETE command produces no safe-destruct observation or audit event; include the long form, and preferably the other accepted argument spellings, in this pattern.

Useful? React with 👍 / 👎.

},
{
id: 'curl-data-flag',
re: /\bcurl\b[^|;&]*\s(-d\b|--data\b|-F\b|--form\b)|\bwget\b[^|;&]*\s(--post-data\b|--post-file\b)/i,
tier: 'observe',
},

// ── infra / containers ───────────────────────────────────────────────────────
//
// Invariant: docker-destructive and kubectl-delete stay OBSERVE because their
Expand Down