aegis-bridge 2.5.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hooks.js CHANGED
@@ -186,9 +186,20 @@ export function registerHookRoutes(app, deps) {
186
186
  // Emit SSE status event only when the hook implies a state change
187
187
  if (newStatus && prevStatus !== newStatus) {
188
188
  switch (eventName) {
189
- case 'Stop':
190
- deps.eventBus.emitStatus(sessionId, 'idle', 'Claude finished (hook: Stop)');
189
+ case 'Stop': {
190
+ // Issue #812: Check if CC is waiting for user input (text-only last assistant message)
191
+ const waiting = await deps.sessions.detectWaitingForInput(sessionId);
192
+ if (waiting) {
193
+ const session = deps.sessions.getSession(sessionId);
194
+ if (session)
195
+ session.status = 'waiting_for_input';
196
+ deps.eventBus.emitStatus(sessionId, 'waiting_for_input', 'Claude finished, waiting for input (hook: Stop)');
197
+ }
198
+ else {
199
+ deps.eventBus.emitStatus(sessionId, 'idle', 'Claude finished (hook: Stop)');
200
+ }
191
201
  break;
202
+ }
192
203
  case 'PreToolUse':
193
204
  case 'PostToolUse':
194
205
  deps.eventBus.emitStatus(sessionId, 'working', 'Claude is working (hook: tool use)');
@@ -9,6 +9,8 @@ export interface ScreenshotOptions {
9
9
  fullPage?: boolean;
10
10
  width?: number;
11
11
  height?: number;
12
+ /** Chromium --host-resolver-rules value to pin DNS (prevents TOCTOU rebinding). */
13
+ hostResolverRule?: string;
12
14
  }
13
15
  export interface ScreenshotResult {
14
16
  screenshot: string;
@@ -23,7 +23,11 @@ export async function captureScreenshot(opts) {
23
23
  if (!playwrightAvailable || !chromium) {
24
24
  throw new Error('Playwright is not installed. Install it with: npx playwright install chromium && npm install -D playwright');
25
25
  }
26
- const browser = await chromium.launch({ headless: true });
26
+ const launchOptions = { headless: true };
27
+ if (opts.hostResolverRule) {
28
+ launchOptions.args = [`--host-resolver-rules=${opts.hostResolverRule}`];
29
+ }
30
+ const browser = await chromium.launch(launchOptions);
27
31
  try {
28
32
  const context = await browser.newContext({
29
33
  viewport: {
package/dist/server.d.ts CHANGED
@@ -7,4 +7,9 @@
7
7
  * Notification channels (Telegram, webhooks, etc.) are pluggable —
8
8
  * the server doesn't know which channels are active.
9
9
  */
10
- export {};
10
+ /**
11
+ * Read the parent PID from /proc/<pid>/status.
12
+ * Uses the PPid line instead of parsing /proc/<pid>/stat,
13
+ * which breaks when the comm field (process name) contains spaces.
14
+ */
15
+ export declare function readPpid(pid: number): number;
package/dist/server.js CHANGED
@@ -23,7 +23,7 @@ import { JsonlWatcher } from './jsonl-watcher.js';
23
23
  import { ChannelManager, TelegramChannel, WebhookChannel, } from './channels/index.js';
24
24
  import { loadConfig } from './config.js';
25
25
  import { captureScreenshot, isPlaywrightAvailable } from './screenshot.js';
26
- import { validateScreenshotUrl, resolveAndCheckIp } from './ssrf.js';
26
+ import { validateScreenshotUrl, resolveAndCheckIp, buildHostResolverRule } from './ssrf.js';
27
27
  import { validateWorkDir } from './validation.js';
28
28
  import { SessionEventBus } from './events.js';
29
29
  import { SSEWriter } from './sse-writer.js';
@@ -461,7 +461,7 @@ async function createSessionHandler(req, reply) {
461
461
  if (typeof safeWorkDir === 'object')
462
462
  return reply.status(400).send({ error: safeWorkDir.error, code: safeWorkDir.code });
463
463
  // Issue #607: Check for an existing idle session with the same workDir
464
- const existing = sessions.findIdleSessionByWorkDir(safeWorkDir);
464
+ const existing = await sessions.findIdleSessionByWorkDir(safeWorkDir);
465
465
  if (existing) {
466
466
  // Send prompt to the existing session if provided
467
467
  let promptDelivery;
@@ -746,10 +746,13 @@ async function screenshotHandler(req, reply) {
746
746
  const urlError = validateScreenshotUrl(url);
747
747
  if (urlError)
748
748
  return reply.status(400).send({ error: urlError });
749
- // Post-DNS-resolution check: resolve hostname and reject private IPs
750
- const ipError = await resolveAndCheckIp(new URL(url).hostname);
751
- if (ipError)
752
- return reply.status(400).send({ error: ipError });
749
+ // DNS-resolution check: resolve hostname and reject private IPs.
750
+ // Returns the resolved IP so we can pin it via --host-resolver-rules to prevent
751
+ // DNS rebinding (TOCTOU) between validation and page.goto().
752
+ const hostname = new URL(url).hostname;
753
+ const dnsResult = await resolveAndCheckIp(hostname);
754
+ if (dnsResult.error)
755
+ return reply.status(400).send({ error: dnsResult.error });
753
756
  // Validate session exists
754
757
  const session = sessions.getSession(req.params.id);
755
758
  if (!session)
@@ -761,7 +764,11 @@ async function screenshotHandler(req, reply) {
761
764
  });
762
765
  }
763
766
  try {
764
- const result = await captureScreenshot({ url, fullPage, width, height });
767
+ // Pin the validated IP via host-resolver-rules to prevent DNS rebinding
768
+ const hostResolverRule = dnsResult.resolvedIp
769
+ ? buildHostResolverRule(hostname, dnsResult.resolvedIp)
770
+ : undefined;
771
+ const result = await captureScreenshot({ url, fullPage, width, height, hostResolverRule });
765
772
  return reply.status(200).send(result);
766
773
  }
767
774
  catch (e) {
@@ -1123,6 +1130,18 @@ function pidExists(pid) {
1123
1130
  return false;
1124
1131
  }
1125
1132
  }
1133
+ /**
1134
+ * Read the parent PID from /proc/<pid>/status.
1135
+ * Uses the PPid line instead of parsing /proc/<pid>/stat,
1136
+ * which breaks when the comm field (process name) contains spaces.
1137
+ */
1138
+ export function readPpid(pid) {
1139
+ const status = readFileSync(`/proc/${pid}/status`, 'utf-8');
1140
+ const match = status.match(/^PPid:\s+(\d+)/m);
1141
+ if (!match)
1142
+ throw new Error(`no PPid line in /proc/${pid}/status`);
1143
+ return parseInt(match[1], 10);
1144
+ }
1126
1145
  /**
1127
1146
  * Check if a PID is an ancestor of the current process.
1128
1147
  */
@@ -1133,7 +1152,7 @@ function isAncestorPid(pid) {
1133
1152
  if (current === pid)
1134
1153
  return true;
1135
1154
  try {
1136
- current = parseInt(readFileSync(`/proc/${current}/stat`, 'utf-8').split(' ')[1], 10);
1155
+ current = readPpid(current);
1137
1156
  }
1138
1157
  catch { /* /proc unavailable or process gone — stop walking */
1139
1158
  break;
package/dist/session.d.ts CHANGED
@@ -132,6 +132,9 @@ export declare class SessionManager {
132
132
  * Returns the previous status for change detection.
133
133
  * Issue #87: Also records hook latency timestamps. */
134
134
  updateStatusFromHook(id: string, hookEvent: string, hookTimestamp?: number): UIState | null;
135
+ /** Issue #812: Detect if CC is waiting for user input by analyzing the JSONL transcript.
136
+ * Returns true if the last assistant message has text content only (no tool_use). */
137
+ detectWaitingForInput(id: string): Promise<boolean>;
135
138
  /** Issue #88: Add an active subagent to a session. */
136
139
  addSubagent(id: string, name: string): void;
137
140
  /** Issue #88: Remove an active subagent from a session. */
@@ -156,8 +159,9 @@ export declare class SessionManager {
156
159
  listSessions(): SessionInfo[];
157
160
  /** Issue #607: Find an idle session for the given workDir.
158
161
  * Returns the most recently active idle session, or null if none found.
159
- * Used to resume existing sessions instead of creating duplicates. */
160
- findIdleSessionByWorkDir(workDir: string): SessionInfo | null;
162
+ * Used to resume existing sessions instead of creating duplicates.
163
+ * Issue #636: Verifies tmux window is still alive before returning. */
164
+ findIdleSessionByWorkDir(workDir: string): Promise<SessionInfo | null>;
161
165
  /** Get health info for a session.
162
166
  * Issue #2: Returns comprehensive health status for orchestrators.
163
167
  */
package/dist/session.js CHANGED
@@ -578,6 +578,34 @@ export class SessionManager {
578
578
  }
579
579
  return prevStatus;
580
580
  }
581
+ /** Issue #812: Detect if CC is waiting for user input by analyzing the JSONL transcript.
582
+ * Returns true if the last assistant message has text content only (no tool_use). */
583
+ async detectWaitingForInput(id) {
584
+ const session = this.state.sessions[id];
585
+ if (!session?.jsonlPath)
586
+ return false;
587
+ try {
588
+ const { raw } = await readNewEntries(session.jsonlPath, 0);
589
+ // Walk backwards to find the last assistant JSONL entry
590
+ for (let i = raw.length - 1; i >= 0; i--) {
591
+ const entry = raw[i];
592
+ if (entry.type !== 'assistant' || !entry.message)
593
+ continue;
594
+ const content = entry.message.content;
595
+ if (typeof content === 'string')
596
+ return true; // text-only message
597
+ if (!Array.isArray(content))
598
+ return false;
599
+ // Check if any content block is a tool_use
600
+ const hasToolUse = content.some((block) => block.type === 'tool_use');
601
+ return !hasToolUse;
602
+ }
603
+ }
604
+ catch {
605
+ // If we can't read the transcript, don't override status
606
+ }
607
+ return false;
608
+ }
581
609
  /** Issue #88: Add an active subagent to a session. */
582
610
  addSubagent(id, name) {
583
611
  const session = this.state.sessions[id];
@@ -662,14 +690,21 @@ export class SessionManager {
662
690
  }
663
691
  /** Issue #607: Find an idle session for the given workDir.
664
692
  * Returns the most recently active idle session, or null if none found.
665
- * Used to resume existing sessions instead of creating duplicates. */
666
- findIdleSessionByWorkDir(workDir) {
693
+ * Used to resume existing sessions instead of creating duplicates.
694
+ * Issue #636: Verifies tmux window is still alive before returning. */
695
+ async findIdleSessionByWorkDir(workDir) {
667
696
  const candidates = Object.values(this.state.sessions).filter((s) => s.workDir === workDir && s.status === 'idle');
668
697
  if (candidates.length === 0)
669
698
  return null;
670
699
  // Return the most recently active session
671
700
  candidates.sort((a, b) => b.lastActivity - a.lastActivity);
672
- return candidates[0];
701
+ // Issue #636: verify tmux window exists before returning
702
+ for (const candidate of candidates) {
703
+ if (await this.tmux.windowExists(candidate.windowId)) {
704
+ return candidate;
705
+ }
706
+ }
707
+ return null;
673
708
  }
674
709
  /** Get health info for a session.
675
710
  * Issue #2: Returns comprehensive health status for orchestrators.
package/dist/ssrf.d.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * - Current network: 0.0.0.0/8
9
9
  * - Unspecified: ::
10
10
  * - IPv6 unique-local: fc00::/7
11
+ * - IPv4-mapped IPv6: ::ffff:x.x.x.x (RFC 4291)
12
+ * - IPv4-compatible IPv6: ::x.x.x.x (deprecated)
11
13
  * - CGNAT: 100.64.0.0/10 (RFC 6598)
12
14
  * - Broadcast: 255.255.255.255
13
15
  * - Multicast: 224.0.0.0/4 (RFC 5771)
@@ -35,16 +37,38 @@ export interface DnsLookupResult {
35
37
  }
36
38
  /** DNS lookup function type for dependency injection. */
37
39
  export type DnsLookupFn = (hostname: string) => Promise<DnsLookupResult>;
40
+ /**
41
+ * Result of DNS resolution with SSRF check.
42
+ * On success, includes the resolved IP address for TOCTOU-safe pinning.
43
+ */
44
+ export interface DnsCheckResult {
45
+ error: string | null;
46
+ resolvedIp: string | null;
47
+ }
38
48
  /**
39
49
  * Resolve a hostname via DNS and check if the resulting IP is private/internal.
40
50
  *
41
51
  * For literal IP addresses, checks directly without DNS resolution.
42
- * Returns null if safe, or an error string if the IP is private.
52
+ * Returns a DnsCheckResult with error string if unsafe, or the resolved IP on success.
53
+ *
54
+ * The resolved IP should be used with Chromium --host-resolver-rules to pin the
55
+ * address and prevent DNS rebinding (TOCTOU) attacks between validation and page.goto().
43
56
  *
44
57
  * @param hostname - Hostname or literal IP to check
45
58
  * @param lookupFn - Optional DNS lookup function (for testing)
46
59
  */
47
- export declare function resolveAndCheckIp(hostname: string, lookupFn?: DnsLookupFn): Promise<string | null>;
60
+ export declare function resolveAndCheckIp(hostname: string, lookupFn?: DnsLookupFn): Promise<DnsCheckResult>;
61
+ /**
62
+ * Build Chromium --host-resolver-rules argument to pin a hostname to a specific IP.
63
+ *
64
+ * This prevents DNS rebinding (TOCTOU) attacks between SSRF validation and page.goto()
65
+ * by ensuring Chromium resolves the hostname to the same IP that was validated.
66
+ *
67
+ * @param hostname - The original hostname from the URL
68
+ * @param resolvedIp - The IP address that was validated as safe
69
+ * @returns The --host-resolver-rules argument string
70
+ */
71
+ export declare function buildHostResolverRule(hostname: string, resolvedIp: string): string;
48
72
  /**
49
73
  * Validate a URL for the screenshot endpoint to prevent SSRF attacks.
50
74
  *
package/dist/ssrf.js CHANGED
@@ -16,6 +16,8 @@ import net from 'node:net';
16
16
  * - Current network: 0.0.0.0/8
17
17
  * - Unspecified: ::
18
18
  * - IPv6 unique-local: fc00::/7
19
+ * - IPv4-mapped IPv6: ::ffff:x.x.x.x (RFC 4291)
20
+ * - IPv4-compatible IPv6: ::x.x.x.x (deprecated)
19
21
  * - CGNAT: 100.64.0.0/10 (RFC 6598)
20
22
  * - Broadcast: 255.255.255.255
21
23
  * - Multicast: 224.0.0.0/4 (RFC 5771)
@@ -70,6 +72,29 @@ export function isPrivateIP(ip) {
70
72
  }
71
73
  // IPv6
72
74
  const lower = ip.toLowerCase();
75
+ // IPv4-mapped IPv6 (::ffff:x.x.x.x, RFC 4291 §2.5.5)
76
+ // Handles dotted-quad form (::ffff:127.0.0.1) and hex form (::ffff:7f00:1).
77
+ // Also handles IPv4-compatible IPv6 (::x.x.x.x, deprecated).
78
+ if (lower.startsWith('::ffff:')) {
79
+ const suffix = lower.slice(7);
80
+ // Dotted quad form: ::ffff:127.0.0.1
81
+ if (net.isIPv4(suffix)) {
82
+ return isPrivateIP(suffix);
83
+ }
84
+ // Hex form: ::ffff:7f00:1 → parse last 32 bits as IPv4
85
+ const hexGroups = suffix.split(':').map(h => parseInt(h, 16));
86
+ if (hexGroups.length === 2 && hexGroups.every(n => !isNaN(n))) {
87
+ const embedded = `${(hexGroups[0] >> 8) & 0xff}.${hexGroups[0] & 0xff}.${(hexGroups[1] >> 8) & 0xff}.${hexGroups[1] & 0xff}`;
88
+ if (net.isIPv4(embedded)) {
89
+ return isPrivateIP(embedded);
90
+ }
91
+ }
92
+ }
93
+ // IPv4-compatible IPv6 (::x.x.x.x, deprecated RFC 4291 §2.5.5)
94
+ const afterPrefix = lower.startsWith('::') && lower !== '::' && lower !== '::1' ? lower.slice(2) : null;
95
+ if (afterPrefix !== null && net.isIPv4(afterPrefix)) {
96
+ return isPrivateIP(afterPrefix);
97
+ }
73
98
  // ::1 (loopback)
74
99
  if (lower === '::1')
75
100
  return true;
@@ -105,8 +130,10 @@ export function validateWebhookUrl(rawUrl) {
105
130
  return 'Invalid URL';
106
131
  }
107
132
  const hostname = parsed.hostname;
133
+ // Strip brackets from IPv6 URLs: [::1] → ::1
134
+ const bareHost = hostname.replace(/^\[|\]$/g, '');
108
135
  // Scheme check — must be HTTPS, or HTTP only for local dev
109
- const isLocalDev = hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost';
136
+ const isLocalDev = bareHost === '127.0.0.1' || bareHost === '::1' || bareHost === 'localhost';
110
137
  if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLocalDev)) {
111
138
  if (parsed.protocol === 'http:') {
112
139
  return 'Only HTTPS URLs are allowed for external hosts';
@@ -114,11 +141,11 @@ export function validateWebhookUrl(rawUrl) {
114
141
  return 'Only HTTPS URLs are allowed';
115
142
  }
116
143
  // Reject *.local hostnames (but allow literal localhost for dev)
117
- if (hostname.endsWith('.local')) {
144
+ if (bareHost.endsWith('.local')) {
118
145
  return 'Localhost URLs are not allowed';
119
146
  }
120
147
  // Reject private/internal IPs (except 127.0.0.1/::1 which are allowed for dev over HTTP)
121
- if (net.isIP(hostname) && isPrivateIP(hostname) && !isLocalDev) {
148
+ if (net.isIP(bareHost) && isPrivateIP(bareHost) && !isLocalDev) {
122
149
  return 'Private/internal IP addresses are not allowed';
123
150
  }
124
151
  return null;
@@ -129,7 +156,10 @@ const defaultLookup = (hostname) => dns.lookup(hostname);
129
156
  * Resolve a hostname via DNS and check if the resulting IP is private/internal.
130
157
  *
131
158
  * For literal IP addresses, checks directly without DNS resolution.
132
- * Returns null if safe, or an error string if the IP is private.
159
+ * Returns a DnsCheckResult with error string if unsafe, or the resolved IP on success.
160
+ *
161
+ * The resolved IP should be used with Chromium --host-resolver-rules to pin the
162
+ * address and prevent DNS rebinding (TOCTOU) attacks between validation and page.goto().
133
163
  *
134
164
  * @param hostname - Hostname or literal IP to check
135
165
  * @param lookupFn - Optional DNS lookup function (for testing)
@@ -138,21 +168,34 @@ export async function resolveAndCheckIp(hostname, lookupFn = defaultLookup) {
138
168
  // Literal IP — check directly
139
169
  if (net.isIP(hostname)) {
140
170
  if (isPrivateIP(hostname)) {
141
- return `DNS resolution points to a private/internal IP: ${hostname}`;
171
+ return { error: `DNS resolution points to a private/internal IP: ${hostname}`, resolvedIp: null };
142
172
  }
143
- return null;
173
+ return { error: null, resolvedIp: hostname };
144
174
  }
145
175
  try {
146
176
  const result = await lookupFn(hostname);
147
177
  if (isPrivateIP(result.address)) {
148
- return `DNS resolution points to a private/internal IP: ${result.address}`;
178
+ return { error: `DNS resolution points to a private/internal IP: ${result.address}`, resolvedIp: null };
149
179
  }
150
- return null;
180
+ return { error: null, resolvedIp: result.address };
151
181
  }
152
182
  catch { /* DNS lookup failed — treat as unsafe */
153
- return `DNS resolution failed for ${hostname}`;
183
+ return { error: `DNS resolution failed for ${hostname}`, resolvedIp: null };
154
184
  }
155
185
  }
186
+ /**
187
+ * Build Chromium --host-resolver-rules argument to pin a hostname to a specific IP.
188
+ *
189
+ * This prevents DNS rebinding (TOCTOU) attacks between SSRF validation and page.goto()
190
+ * by ensuring Chromium resolves the hostname to the same IP that was validated.
191
+ *
192
+ * @param hostname - The original hostname from the URL
193
+ * @param resolvedIp - The IP address that was validated as safe
194
+ * @returns The --host-resolver-rules argument string
195
+ */
196
+ export function buildHostResolverRule(hostname, resolvedIp) {
197
+ return `MAP ${hostname} ${resolvedIp}`;
198
+ }
156
199
  /**
157
200
  * Validate a URL for the screenshot endpoint to prevent SSRF attacks.
158
201
  *
@@ -178,12 +221,14 @@ export function validateScreenshotUrl(rawUrl) {
178
221
  return 'Only http and https URLs are allowed';
179
222
  }
180
223
  const hostname = parsed.hostname;
224
+ // Strip brackets from IPv6 URLs: [::1] → ::1
225
+ const bareHost = hostname.replace(/^\[|\]$/g, '');
181
226
  // Reject localhost / *.local hostnames
182
- if (hostname === 'localhost' || hostname.endsWith('.local')) {
227
+ if (bareHost === 'localhost' || bareHost.endsWith('.local')) {
183
228
  return 'Localhost URLs are not allowed';
184
229
  }
185
230
  // Reject private/internal IPs
186
- if (net.isIP(hostname) && isPrivateIP(hostname)) {
231
+ if (net.isIP(bareHost) && isPrivateIP(bareHost)) {
187
232
  return 'Private/internal IP addresses are not allowed';
188
233
  }
189
234
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "type": "module",
5
5
  "description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
6
6
  "main": "dist/server.js",
@@ -65,6 +65,7 @@
65
65
  "devDependencies": {
66
66
  "@types/node": "^20.0.0",
67
67
  "@types/ws": "^8.18.1",
68
+ "lockfile-lint": "5.0.0",
68
69
  "typescript": "^6.0.2",
69
70
  "vitest": "^4.1.2"
70
71
  }