Skip to content

Commit 15f7b5b

Browse files
TeoSlayerteovlclaudematthew-pilot
authored
feat(plugin): AEGIS scan-pipe on all inbound Pilot messages (#6)
* fix(plugin): regenerate package-lock.json for openclaw@2026.6.1 The plugin's package.json was bumped to openclaw ^2026.6.1 but the lockfile still pinned 2026.5.26, so npm ci has been failing on every push since the bump: Invalid: lock file's openclaw@2026.5.26 does not satisfy openclaw@2026.6.1 Re-runs npm install --package-lock-only to refresh the lockfile. Confirmed with npm ci locally — exit 0. * feat(plugin): AEGIS scan-pipe on all inbound Pilot messages Scan message text and media captions through `aegis scan-pipe` before dispatching to the agent. Exit 2 from aegis drops the message with a warning — prevents prompt injection over the Pilot network from reaching the LLM turn. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Run AEGIS scan-pipe async to unblock the event loop Replace the per-message spawnSync("aegis","scan-pipe") in the inbound pipeline with a non-blocking execFile wrapper. The dispatch pipeline is fully await-based; spawning synchronously parked the Node event loop for the whole subprocess lifetime on every inbound message, starving every other peer's I/O. The new createAegisScan helper feeds candidate text on stdin, awaits the child exit, and keeps the exit-code contract (status 2 = block, rule on stdout). Spawn errors and timeouts resolve fail-open so a scanner outage never silently drops all inbound DMs. The scanner is injectable via InboundDeps/LifecycleDeps so tests run without execing a real binary. Add coverage for the scan-pipe paths: clean pass-through, flagged/blocked drop, and the error/timeout fail-open branch, for both the text and media caption code paths. --------- Co-authored-by: Teodor Calin <teodor@vulturelabs.io> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: matthew-pilot <matthew@vulturelabs.io>
1 parent 0e4a0c7 commit 15f7b5b

11 files changed

Lines changed: 464 additions & 4 deletions

plugin/src/aegis-scan.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// AEGIS scan-pipe — prompt-injection screening for inbound message text.
2+
//
3+
// Runs the external `aegis scan-pipe` tool, feeding the candidate text on
4+
// stdin. AEGIS signals a verdict via process exit code:
5+
// 0 → clean (allow)
6+
// 2 → flagged (block); the matched rule is printed on stdout
7+
// * → any other status / spawn error / timeout → treated as inconclusive,
8+
// and the caller fails OPEN (allows the message). A scanner outage must
9+
// not silently swallow every inbound DM.
10+
//
11+
// This is intentionally async: the inbound pipeline is fully `await`-based and
12+
// runs one scan per message. A synchronous `spawnSync` here would block the
13+
// Node event loop for the whole subprocess lifetime on every message, starving
14+
// every other connection's I/O. We spawn non-blocking and await the result.
15+
16+
import { execFile } from "node:child_process";
17+
18+
/** Outcome of an AEGIS scan. */
19+
export type AegisScanResult = {
20+
/** True when AEGIS flagged the text (exit code 2). */
21+
blocked: boolean;
22+
/** The matched rule (stdout) when blocked; empty otherwise. */
23+
rule: string;
24+
};
25+
26+
/** Pluggable scanner signature so the pipeline can be tested without execing. */
27+
export type AegisScan = (text: string) => Promise<AegisScanResult>;
28+
29+
export type AegisScanOptions = {
30+
/** Subprocess hard timeout in ms. Default 500. */
31+
timeoutMs?: number;
32+
/** Binary to invoke. Default "aegis". */
33+
command?: string;
34+
};
35+
36+
/**
37+
* Default scanner: spawns `aegis scan-pipe` and writes `text` to its stdin.
38+
*
39+
* Non-blocking — resolves once the child exits (or the timeout kills it).
40+
* Equivalent allow/block contract to the previous spawnSync implementation
41+
* (exit 2 = block), but without parking the event loop.
42+
*/
43+
export function createAegisScan(opts: AegisScanOptions = {}): AegisScan {
44+
const timeout = opts.timeoutMs ?? 500;
45+
const command = opts.command ?? "aegis";
46+
47+
return (text: string): Promise<AegisScanResult> =>
48+
new Promise<AegisScanResult>((resolve) => {
49+
const child = execFile(
50+
command,
51+
["scan-pipe"],
52+
{ timeout, encoding: "utf8", maxBuffer: 1024 * 1024 },
53+
(err, stdout) => {
54+
// `err.code` carries the exit status for a non-zero exit; on a
55+
// timeout the child is killed and `err.killed` is set with no code.
56+
const status =
57+
err && typeof (err as { code?: unknown }).code === "number"
58+
? (err as { code: number }).code
59+
: err
60+
? null // spawn error, timeout, or signal — inconclusive
61+
: 0;
62+
63+
if (status === 2) {
64+
resolve({ blocked: true, rule: (stdout ?? "").trim() });
65+
return;
66+
}
67+
// Clean (0) or inconclusive (null / other) → fail open.
68+
resolve({ blocked: false, rule: "" });
69+
},
70+
);
71+
72+
// Feed the candidate text and close stdin so AEGIS sees EOF.
73+
child.stdin?.on("error", () => {
74+
// A write race (e.g. child already exited) must not crash us; the
75+
// exec callback above still resolves the promise.
76+
});
77+
child.stdin?.end(text);
78+
});
79+
}

plugin/src/inbound.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { join } from "node:path";
1212
import { tmpdir } from "node:os";
1313

1414
import type { ResolvedPilotAccount } from "./config.js";
15+
import { createAegisScan, type AegisScan } from "./aegis-scan.js";
1516
import { decideAllowlist } from "./allowlist.js";
1617
import type { PeerAddressCache } from "./peer-address.js";
1718
import type { IncomingDatagram, Transport } from "./transport.js";
@@ -102,6 +103,13 @@ export type InboundDeps = {
102103
* common case but wrong for multi-network deployments.
103104
*/
104105
peerAddressCache?: PeerAddressCache;
106+
/**
107+
* AEGIS prompt-injection scanner. Runs on every inbound text / media
108+
* caption before dispatch; a `blocked` result drops the message. Async and
109+
* non-blocking so one message's scan never parks the event loop. Defaults
110+
* to the real `aegis scan-pipe` subprocess; tests inject a stub.
111+
*/
112+
aegisScan?: AegisScan;
105113
};
106114

107115
export class InboundPipeline {
@@ -114,12 +122,14 @@ export class InboundPipeline {
114122
private recentOrder: Array<{ id: string; ts: number }> = [];
115123
private readonly mediaDir: string;
116124
private readonly maxMediaBytes: number;
125+
private readonly aegisScan: AegisScan;
117126

118127
constructor(deps: InboundDeps) {
119128
this.deps = deps;
120129
this.recent = deps.recentIds ?? new Set();
121130
this.mediaDir = deps.mediaDir ?? join(tmpdir(), "claw-pilot-inbound");
122131
this.maxMediaBytes = deps.maxMediaBytes ?? 25 * 1024 * 1024;
132+
this.aegisScan = deps.aegisScan ?? createAegisScan();
123133
try {
124134
mkdirSync(this.mediaDir, { recursive: true });
125135
} catch (e) {
@@ -272,6 +282,21 @@ export class InboundPipeline {
272282
// class of UI mystery.
273283
void this.sendAck(reassembled.id, peer);
274284

285+
// AEGIS scan: check message text for prompt injection before dispatching
286+
// to the agent. Async + non-blocking so one slow scan can't stall the
287+
// recv loop for every other peer. A scanner outage fails open.
288+
if (reassembled.text) {
289+
const scan = await this.aegisScan(reassembled.text);
290+
if (scan.blocked) {
291+
this.deps.logger.warn("pilot inbound: AEGIS blocked message", {
292+
id: reassembled.id,
293+
peer,
294+
rule: scan.rule,
295+
});
296+
return;
297+
}
298+
}
299+
275300
try {
276301
await this.deps.dispatch({
277302
accountId: this.deps.account.accountId,
@@ -343,6 +368,20 @@ export class InboundPipeline {
343368
// comment in handleDatagram above.
344369
void this.sendAck(out.id, peer);
345370

371+
// AEGIS scan: check caption text for injection before dispatch. Same
372+
// async, fail-open contract as the text path above.
373+
if (out.caption) {
374+
const scan = await this.aegisScan(out.caption);
375+
if (scan.blocked) {
376+
this.deps.logger.warn("pilot inbound: AEGIS blocked media caption", {
377+
id: out.id,
378+
peer,
379+
rule: scan.rule,
380+
});
381+
return;
382+
}
383+
}
384+
346385
try {
347386
await this.deps.dispatch({
348387
accountId: this.deps.account.accountId,

plugin/src/lifecycle.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { OpenClawConfig } from "./openclaw-types.js";
99
import type { PilotAccountConfig, ResolvedPilotAccount } from "./config.js";
1010
import { DEFAULT_ACCOUNT_ID, resolveAccount } from "./config.js";
1111
import { InboundPipeline, type InboundLogger } from "./inbound.js";
12+
import type { AegisScan } from "./aegis-scan.js";
1213
import { Outbox } from "./outbox.js";
1314
import { PeerAddressCache } from "./peer-address.js";
1415
import { getPilotRuntime } from "./runtime-api.js";
@@ -49,6 +50,13 @@ export type LifecycleDeps = {
4950
* Periodic drain interval (ms). Defaults to 30s. Tests can shorten.
5051
*/
5152
outboxDrainIntervalMs?: number;
53+
/**
54+
* AEGIS prompt-injection scanner passed through to each account's
55+
* InboundPipeline. Defaults (in the pipeline) to the real `aegis scan-pipe`
56+
* subprocess; tests inject a stub so the integration path stays
57+
* deterministic and never execs a real binary.
58+
*/
59+
aegisScan?: AegisScan;
5260
};
5361

5462
export class PilotLifecycle {
@@ -213,6 +221,7 @@ export class PilotLifecycle {
213221
account,
214222
dispatch,
215223
logger: this.deps.logger,
224+
aegisScan: this.deps.aegisScan,
216225
// Reuse the same transport for ACKs — the sender Driver inside it is
217226
// the one with permission to send to the peer.
218227
ackTransport: transport,

plugin/tests/ack.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe("inbound ACK", () => {
5151
dispatched.push(m);
5252
},
5353
logger: silentLogger(),
54+
aegisScan: async () => ({ blocked: false, rule: "" }),
5455
ackTransport: transport,
5556
});
5657
pipeline.attach(transport);
@@ -85,6 +86,7 @@ describe("inbound ACK", () => {
8586
dispatched.push(m);
8687
},
8788
logger: silentLogger(),
89+
aegisScan: async () => ({ blocked: false, rule: "" }),
8890
ackTransport: transport,
8991
});
9092
pipeline.attach(transport);
@@ -125,6 +127,7 @@ describe("inbound ACK", () => {
125127
/* noop */
126128
},
127129
logger: silentLogger(),
130+
aegisScan: async () => ({ blocked: false, rule: "" }),
128131
});
129132
pipeline.attach(transport);
130133

@@ -148,6 +151,7 @@ describe("inbound ACK", () => {
148151
/* noop */
149152
},
150153
logger: silentLogger(),
154+
aegisScan: async () => ({ blocked: false, rule: "" }),
151155
ackTransport: transport,
152156
});
153157
pipeline.attach(transport);

0 commit comments

Comments
 (0)