aegis-bridge 0.1.0
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/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { chmod } from 'node:fs/promises';
|
|
3
|
+
const PERMISSIONS_TIMEOUT_MS = 5_000;
|
|
4
|
+
export function buildWindowsIcaclsArgs(filePath, account) {
|
|
5
|
+
return [filePath, '/inheritance:r', '/grant:r', `${account}:(R,W)`];
|
|
6
|
+
}
|
|
7
|
+
function runIcacls(filePath, account) {
|
|
8
|
+
const args = buildWindowsIcaclsArgs(filePath, account);
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
execFile('icacls', args, { timeout: PERMISSIONS_TIMEOUT_MS }, (error) => {
|
|
11
|
+
if (error) {
|
|
12
|
+
reject(error);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
resolve();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export async function secureFilePermissions(filePath, platform = process.platform) {
|
|
20
|
+
if (platform !== 'win32') {
|
|
21
|
+
await chmod(filePath, 0o600);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const username = process.env.USERNAME;
|
|
25
|
+
if (!username) {
|
|
26
|
+
console.warn(`Windows permission hardening skipped for ${filePath}: USERNAME is not set`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const account = process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${username}` : username;
|
|
30
|
+
try {
|
|
31
|
+
await runIcacls(filePath, account);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
35
|
+
console.warn(`Windows permission hardening failed for ${filePath}: ${detail}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handshake.ts — Capability handshake schema and negotiation for Aegis/Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Issue #885: Defines a formal protocolVersion + capabilities negotiation so
|
|
5
|
+
* that clients and Aegis can agree on supported feature set before using
|
|
6
|
+
* advanced integration paths. Prevents version-drift breakage.
|
|
7
|
+
*/
|
|
8
|
+
/** Current protocol version advertised by this Aegis build. */
|
|
9
|
+
export declare const AEGIS_PROTOCOL_VERSION = "1";
|
|
10
|
+
/** Minimum protocol version this Aegis build still accepts. */
|
|
11
|
+
export declare const AEGIS_MIN_PROTOCOL_VERSION = "1";
|
|
12
|
+
/**
|
|
13
|
+
* All capabilities Aegis supports in this build.
|
|
14
|
+
* Capabilities are additive; absence means the feature is unavailable/disabled.
|
|
15
|
+
*/
|
|
16
|
+
export declare const AEGIS_CAPABILITIES: readonly ["session.create", "session.resume", "session.approve", "session.transcript", "session.transcript.cursor", "session.events.sse", "session.events.cursor", "session.screenshot", "hooks.pre_tool_use", "hooks.post_tool_use", "hooks.notification", "hooks.stop", "swarm", "metrics"];
|
|
17
|
+
export type AegisCapability = (typeof AEGIS_CAPABILITIES)[number];
|
|
18
|
+
/**
|
|
19
|
+
* Feature gates that client integrations should check before enabling
|
|
20
|
+
* behavior that depends on newer protocol/capability support.
|
|
21
|
+
*/
|
|
22
|
+
export declare const HANDSHAKE_FEATURE_REQUIREMENTS: {
|
|
23
|
+
readonly cursorReplay: readonly ["session.transcript.cursor"];
|
|
24
|
+
readonly transcriptRead: readonly ["session.transcript"];
|
|
25
|
+
readonly sseEvents: readonly ["session.events.sse"];
|
|
26
|
+
readonly permissionControl: readonly ["session.approve"];
|
|
27
|
+
readonly screenshots: readonly ["session.screenshot"];
|
|
28
|
+
readonly hookLifecycle: readonly ["hooks.pre_tool_use", "hooks.post_tool_use"];
|
|
29
|
+
};
|
|
30
|
+
export type HandshakeFeature = keyof typeof HANDSHAKE_FEATURE_REQUIREMENTS;
|
|
31
|
+
export type HandshakeFallbackMode = 'none' | 'legacy-defaults' | 'incompatible-protocol' | 'invalid-protocol';
|
|
32
|
+
/** Request body for POST /v1/handshake */
|
|
33
|
+
export interface HandshakeRequest {
|
|
34
|
+
protocolVersion: string;
|
|
35
|
+
clientCapabilities?: string[];
|
|
36
|
+
clientVersion?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Response shape for POST /v1/handshake */
|
|
39
|
+
export interface HandshakeResponse {
|
|
40
|
+
protocolVersion: string;
|
|
41
|
+
serverCapabilities: AegisCapability[];
|
|
42
|
+
negotiatedCapabilities: AegisCapability[];
|
|
43
|
+
featureGates: Record<HandshakeFeature, boolean>;
|
|
44
|
+
fallbackMode: HandshakeFallbackMode;
|
|
45
|
+
warnings: string[];
|
|
46
|
+
compatible: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** Compute boolean feature gates from a negotiated capability set. */
|
|
49
|
+
export declare function computeFeatureGates(capabilities: readonly AegisCapability[]): Record<HandshakeFeature, boolean>;
|
|
50
|
+
/** Helper for checking one feature gate directly from a handshake response. */
|
|
51
|
+
export declare function isFeatureEnabled(response: HandshakeResponse, feature: HandshakeFeature): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Negotiate capabilities between a client request and this Aegis build.
|
|
54
|
+
*
|
|
55
|
+
* Rules:
|
|
56
|
+
* - If client protocolVersion < AEGIS_MIN_PROTOCOL_VERSION → not compatible, add warning, return empty negotiatedCapabilities
|
|
57
|
+
* - If client protocolVersion > AEGIS_PROTOCOL_VERSION → compatible but add forward-compat warning
|
|
58
|
+
* - negotiatedCapabilities = intersection of server caps and clientCapabilities (or all server caps if client sends none)
|
|
59
|
+
*/
|
|
60
|
+
export declare function negotiate(req: HandshakeRequest): HandshakeResponse;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handshake.ts — Capability handshake schema and negotiation for Aegis/Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Issue #885: Defines a formal protocolVersion + capabilities negotiation so
|
|
5
|
+
* that clients and Aegis can agree on supported feature set before using
|
|
6
|
+
* advanced integration paths. Prevents version-drift breakage.
|
|
7
|
+
*/
|
|
8
|
+
/** Current protocol version advertised by this Aegis build. */
|
|
9
|
+
export const AEGIS_PROTOCOL_VERSION = '1';
|
|
10
|
+
/** Minimum protocol version this Aegis build still accepts. */
|
|
11
|
+
export const AEGIS_MIN_PROTOCOL_VERSION = '1';
|
|
12
|
+
/**
|
|
13
|
+
* All capabilities Aegis supports in this build.
|
|
14
|
+
* Capabilities are additive; absence means the feature is unavailable/disabled.
|
|
15
|
+
*/
|
|
16
|
+
export const AEGIS_CAPABILITIES = [
|
|
17
|
+
'session.create',
|
|
18
|
+
'session.resume',
|
|
19
|
+
'session.approve',
|
|
20
|
+
'session.transcript',
|
|
21
|
+
'session.transcript.cursor', // Issue #883: cursor-based replay
|
|
22
|
+
'session.events.sse',
|
|
23
|
+
'session.events.cursor',
|
|
24
|
+
'session.screenshot',
|
|
25
|
+
'hooks.pre_tool_use',
|
|
26
|
+
'hooks.post_tool_use',
|
|
27
|
+
'hooks.notification',
|
|
28
|
+
'hooks.stop',
|
|
29
|
+
'swarm',
|
|
30
|
+
'metrics',
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Feature gates that client integrations should check before enabling
|
|
34
|
+
* behavior that depends on newer protocol/capability support.
|
|
35
|
+
*/
|
|
36
|
+
export const HANDSHAKE_FEATURE_REQUIREMENTS = {
|
|
37
|
+
cursorReplay: ['session.transcript.cursor'],
|
|
38
|
+
transcriptRead: ['session.transcript'],
|
|
39
|
+
sseEvents: ['session.events.sse'],
|
|
40
|
+
permissionControl: ['session.approve'],
|
|
41
|
+
screenshots: ['session.screenshot'],
|
|
42
|
+
hookLifecycle: ['hooks.pre_tool_use', 'hooks.post_tool_use'],
|
|
43
|
+
};
|
|
44
|
+
/** Compute boolean feature gates from a negotiated capability set. */
|
|
45
|
+
export function computeFeatureGates(capabilities) {
|
|
46
|
+
const enabled = new Set(capabilities);
|
|
47
|
+
return Object.fromEntries(Object.entries(HANDSHAKE_FEATURE_REQUIREMENTS).map(([feature, required]) => [
|
|
48
|
+
feature,
|
|
49
|
+
required.every(capability => enabled.has(capability)),
|
|
50
|
+
]));
|
|
51
|
+
}
|
|
52
|
+
/** Helper for checking one feature gate directly from a handshake response. */
|
|
53
|
+
export function isFeatureEnabled(response, feature) {
|
|
54
|
+
return response.featureGates[feature] === true;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Negotiate capabilities between a client request and this Aegis build.
|
|
58
|
+
*
|
|
59
|
+
* Rules:
|
|
60
|
+
* - If client protocolVersion < AEGIS_MIN_PROTOCOL_VERSION → not compatible, add warning, return empty negotiatedCapabilities
|
|
61
|
+
* - If client protocolVersion > AEGIS_PROTOCOL_VERSION → compatible but add forward-compat warning
|
|
62
|
+
* - negotiatedCapabilities = intersection of server caps and clientCapabilities (or all server caps if client sends none)
|
|
63
|
+
*/
|
|
64
|
+
export function negotiate(req) {
|
|
65
|
+
const warnings = [];
|
|
66
|
+
const serverCapabilities = [...AEGIS_CAPABILITIES];
|
|
67
|
+
// Parse major version numbers for comparison
|
|
68
|
+
const clientMajor = parseInt(req.protocolVersion, 10);
|
|
69
|
+
const serverMajor = parseInt(AEGIS_PROTOCOL_VERSION, 10);
|
|
70
|
+
const minMajor = parseInt(AEGIS_MIN_PROTOCOL_VERSION, 10);
|
|
71
|
+
if (isNaN(clientMajor)) {
|
|
72
|
+
const negotiatedCapabilities = [];
|
|
73
|
+
return {
|
|
74
|
+
protocolVersion: AEGIS_PROTOCOL_VERSION,
|
|
75
|
+
serverCapabilities,
|
|
76
|
+
negotiatedCapabilities,
|
|
77
|
+
featureGates: computeFeatureGates(negotiatedCapabilities),
|
|
78
|
+
fallbackMode: 'invalid-protocol',
|
|
79
|
+
warnings: [`Unrecognized protocolVersion format: "${req.protocolVersion}". Expected integer string.`],
|
|
80
|
+
compatible: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (clientMajor < minMajor) {
|
|
84
|
+
const negotiatedCapabilities = [];
|
|
85
|
+
return {
|
|
86
|
+
protocolVersion: AEGIS_PROTOCOL_VERSION,
|
|
87
|
+
serverCapabilities,
|
|
88
|
+
negotiatedCapabilities,
|
|
89
|
+
featureGates: computeFeatureGates(negotiatedCapabilities),
|
|
90
|
+
fallbackMode: 'incompatible-protocol',
|
|
91
|
+
warnings: [
|
|
92
|
+
`Client protocolVersion ${req.protocolVersion} is below minimum supported version ${AEGIS_MIN_PROTOCOL_VERSION}. Upgrade required.`,
|
|
93
|
+
],
|
|
94
|
+
compatible: false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (clientMajor > serverMajor) {
|
|
98
|
+
warnings.push(`Client protocolVersion ${req.protocolVersion} is newer than server version ${AEGIS_PROTOCOL_VERSION}. Some client features may be unavailable.`);
|
|
99
|
+
}
|
|
100
|
+
// Intersect: client declares what it supports; server only enables what it also supports
|
|
101
|
+
let negotiatedCapabilities;
|
|
102
|
+
if (!req.clientCapabilities || req.clientCapabilities.length === 0) {
|
|
103
|
+
// Client omitted capabilities → default to full set for backward compatibility.
|
|
104
|
+
negotiatedCapabilities = serverCapabilities;
|
|
105
|
+
warnings.push('Client did not provide clientCapabilities; using legacy-default capability negotiation.');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const serverSet = new Set(serverCapabilities);
|
|
109
|
+
const unknown = req.clientCapabilities.filter(c => !serverSet.has(c));
|
|
110
|
+
if (unknown.length > 0) {
|
|
111
|
+
warnings.push(`Unknown client capabilities ignored: ${unknown.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
negotiatedCapabilities = req.clientCapabilities.filter((c) => serverSet.has(c));
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
protocolVersion: AEGIS_PROTOCOL_VERSION,
|
|
117
|
+
serverCapabilities,
|
|
118
|
+
negotiatedCapabilities,
|
|
119
|
+
featureGates: computeFeatureGates(negotiatedCapabilities),
|
|
120
|
+
fallbackMode: req.clientCapabilities && req.clientCapabilities.length > 0 ? 'none' : 'legacy-defaults',
|
|
121
|
+
warnings,
|
|
122
|
+
compatible: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-settings.ts — Generate CC settings.json with HTTP hooks for Aegis.
|
|
3
|
+
*
|
|
4
|
+
* When Aegis creates a CC session, it writes a per-session settings file
|
|
5
|
+
* that configures HTTP hooks pointing to Aegis's hook receiver endpoint.
|
|
6
|
+
* The file is passed to CC via the `--settings` CLI flag.
|
|
7
|
+
*
|
|
8
|
+
* Only events that support `type: "http"` hooks are included:
|
|
9
|
+
* Stop, PreToolUse, PostToolUse, PermissionRequest, TaskCompleted
|
|
10
|
+
*
|
|
11
|
+
* Events like Notification, SessionEnd, etc. only support `type: "command"`
|
|
12
|
+
* and are excluded.
|
|
13
|
+
*
|
|
14
|
+
* Issue #169: Phase 2 — Inject CC settings.json with HTTP hooks.
|
|
15
|
+
*/
|
|
16
|
+
/** Build a normalized path to .claude/settings.local.json for Unix and Windows workDirs. */
|
|
17
|
+
export declare function buildProjectSettingsPath(workDir: string, platform?: NodeJS.Platform): string;
|
|
18
|
+
/** CC hook events that support `type: "http"`.
|
|
19
|
+
*
|
|
20
|
+
* All CC hook events support HTTP hooks. We register the most useful ones
|
|
21
|
+
* for Aegis status detection and event forwarding.
|
|
22
|
+
*
|
|
23
|
+
* Excluded (low value for Aegis):
|
|
24
|
+
* - InstructionsLoaded, ConfigChange (informational)
|
|
25
|
+
* - WorktreeCreate, WorktreeRemove (worktree management)
|
|
26
|
+
* - Elicitation, ElicitationResult (MCP-specific)
|
|
27
|
+
*/
|
|
28
|
+
declare const HTTP_HOOK_EVENTS: readonly ["Stop", "StopFailure", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "TaskCompleted", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart", "SubagentStop", "PreCompact", "PostCompact", "FileChanged", "CwdChanged", "Notification", "TeammateIdle", "WorktreeCreate", "WorktreeRemove", "Elicitation", "ElicitationResult"];
|
|
29
|
+
export { HTTP_HOOK_EVENTS };
|
|
30
|
+
export type HttpHookEvent = typeof HTTP_HOOK_EVENTS[number];
|
|
31
|
+
/** Shape of a single HTTP hook entry in CC settings.json. */
|
|
32
|
+
interface HttpHookConfig {
|
|
33
|
+
type: 'http';
|
|
34
|
+
url: string;
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
/** Shape of the `hooks` section in CC settings.json. */
|
|
38
|
+
export interface HookSettings {
|
|
39
|
+
hooks: Record<string, Array<{
|
|
40
|
+
matcher?: string;
|
|
41
|
+
hooks: HttpHookConfig[];
|
|
42
|
+
}>>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Generate the hooks section of a CC settings.json for a given session.
|
|
46
|
+
*
|
|
47
|
+
* @param baseUrl - Aegis base URL (e.g. "http://localhost:9100")
|
|
48
|
+
* @param sessionId - Aegis session ID (used as query param for routing)
|
|
49
|
+
* @param hookSecret - Per-session secret for hook URL authentication (Issue #629)
|
|
50
|
+
*/
|
|
51
|
+
export declare function generateHookSettings(baseUrl: string, sessionId: string, hookSecret?: string): HookSettings;
|
|
52
|
+
/**
|
|
53
|
+
* Write hook settings to a temporary file and return its path.
|
|
54
|
+
*
|
|
55
|
+
* Issue #339: Reads .claude/settings.local.json from workDir and deep-merges
|
|
56
|
+
* hook settings into it, so CC gets both project settings (env vars, permissions,
|
|
57
|
+
* bypassPermissions) AND Aegis hooks in a single --settings file.
|
|
58
|
+
*
|
|
59
|
+
* @param baseUrl - Aegis base URL
|
|
60
|
+
* @param sessionId - Aegis session ID
|
|
61
|
+
* @param workDir - Project working directory (to read settings.local.json from)
|
|
62
|
+
* @returns Path to the temporary settings file
|
|
63
|
+
*/
|
|
64
|
+
export declare function writeHookSettingsFile(baseUrl: string, sessionId: string, hookSecret: string, workDir?: string): Promise<string>;
|
|
65
|
+
/**
|
|
66
|
+
* Clean up a hook settings temp file.
|
|
67
|
+
*
|
|
68
|
+
* @param filePath - Path to the temporary settings file
|
|
69
|
+
*/
|
|
70
|
+
export declare function cleanupHookSettingsFile(filePath: string): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
|
|
73
|
+
*
|
|
74
|
+
* When sessions die, their hook URLs remain in settings.local.json.
|
|
75
|
+
* On restart, CC loads these dead hooks and crashes.
|
|
76
|
+
*
|
|
77
|
+
* @param workDir - Project working directory
|
|
78
|
+
* @param activeSessionIds - Set of currently active session IDs
|
|
79
|
+
*/
|
|
80
|
+
export declare function cleanupStaleSessionHooks(workDir: string, activeSessionIds: Set<string>): Promise<void>;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-settings.ts — Generate CC settings.json with HTTP hooks for Aegis.
|
|
3
|
+
*
|
|
4
|
+
* When Aegis creates a CC session, it writes a per-session settings file
|
|
5
|
+
* that configures HTTP hooks pointing to Aegis's hook receiver endpoint.
|
|
6
|
+
* The file is passed to CC via the `--settings` CLI flag.
|
|
7
|
+
*
|
|
8
|
+
* Only events that support `type: "http"` hooks are included:
|
|
9
|
+
* Stop, PreToolUse, PostToolUse, PermissionRequest, TaskCompleted
|
|
10
|
+
*
|
|
11
|
+
* Events like Notification, SessionEnd, etc. only support `type: "command"`
|
|
12
|
+
* and are excluded.
|
|
13
|
+
*
|
|
14
|
+
* Issue #169: Phase 2 — Inject CC settings.json with HTTP hooks.
|
|
15
|
+
*/
|
|
16
|
+
import { readFile, writeFile, unlink, mkdir, rmdir } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { ccSettingsSchema, containsTraversalSegment } from './validation.js';
|
|
22
|
+
import { secureFilePermissions } from './file-utils.js';
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
function parseSettingsWithFallback(raw) {
|
|
27
|
+
// Windows editors may write UTF-8 BOM; strip it so JSON.parse does not fail.
|
|
28
|
+
const json = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
29
|
+
if (!isRecord(json))
|
|
30
|
+
return undefined;
|
|
31
|
+
const parsed = ccSettingsSchema.safeParse(json);
|
|
32
|
+
if (parsed.success)
|
|
33
|
+
return parsed.data;
|
|
34
|
+
// Preserve unknown/extra fields (including env vars) even when schema validation fails.
|
|
35
|
+
return json;
|
|
36
|
+
}
|
|
37
|
+
function normalizeHookBaseUrl(baseUrl) {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(baseUrl);
|
|
40
|
+
if (url.hostname === '0.0.0.0' || url.hostname === '::' || url.hostname === '[::]') {
|
|
41
|
+
url.hostname = '127.0.0.1';
|
|
42
|
+
}
|
|
43
|
+
return url.origin;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return baseUrl.replace('0.0.0.0', '127.0.0.1');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Build a normalized path to .claude/settings.local.json for Unix and Windows workDirs. */
|
|
50
|
+
export function buildProjectSettingsPath(workDir, platform = process.platform) {
|
|
51
|
+
let normalizedWorkDir = platform === 'win32'
|
|
52
|
+
? workDir.replace(/\//g, '\\')
|
|
53
|
+
: workDir.replace(/\\/g, '/');
|
|
54
|
+
// On Linux, resolve() prepends CWD to Windows paths like "D:\Users\dev"
|
|
55
|
+
// because Linux doesn't understand Windows drive letters. Only resolve
|
|
56
|
+
// paths that are NOT already absolute on the target platform.
|
|
57
|
+
const isWinAbs = /^[A-Za-z]:\\/.test(normalizedWorkDir);
|
|
58
|
+
const isUnixAbs = /^\//.test(normalizedWorkDir);
|
|
59
|
+
const alreadyAbs = (platform === 'win32' && isWinAbs) || isUnixAbs;
|
|
60
|
+
if (!alreadyAbs)
|
|
61
|
+
normalizedWorkDir = resolve(normalizedWorkDir);
|
|
62
|
+
// Normalize separators to match the target platform.
|
|
63
|
+
const result = join(normalizedWorkDir, '.claude', 'settings.local.json');
|
|
64
|
+
return platform === 'win32' ? result.replace(/\//g, '\\') : result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Validate a workDir path for use in hook settings resolution.
|
|
68
|
+
* Defense-in-depth against path traversal: rejects paths containing ".." segments
|
|
69
|
+
* or that resolve outside the provided workDir.
|
|
70
|
+
*
|
|
71
|
+
* @returns Sanitized absolute path, or undefined if validation fails.
|
|
72
|
+
*/
|
|
73
|
+
function validateWorkDirPath(workDir) {
|
|
74
|
+
if (containsTraversalSegment(workDir))
|
|
75
|
+
return undefined;
|
|
76
|
+
return resolve(workDir);
|
|
77
|
+
}
|
|
78
|
+
/** CC hook events that support `type: "http"`.
|
|
79
|
+
*
|
|
80
|
+
* All CC hook events support HTTP hooks. We register the most useful ones
|
|
81
|
+
* for Aegis status detection and event forwarding.
|
|
82
|
+
*
|
|
83
|
+
* Excluded (low value for Aegis):
|
|
84
|
+
* - InstructionsLoaded, ConfigChange (informational)
|
|
85
|
+
* - WorktreeCreate, WorktreeRemove (worktree management)
|
|
86
|
+
* - Elicitation, ElicitationResult (MCP-specific)
|
|
87
|
+
*/
|
|
88
|
+
const HTTP_HOOK_EVENTS = [
|
|
89
|
+
// Status detection (highest value)
|
|
90
|
+
'Stop',
|
|
91
|
+
'StopFailure',
|
|
92
|
+
'PreToolUse',
|
|
93
|
+
'PostToolUse',
|
|
94
|
+
'PostToolUseFailure',
|
|
95
|
+
'PermissionRequest',
|
|
96
|
+
'TaskCompleted',
|
|
97
|
+
// Session lifecycle
|
|
98
|
+
'SessionStart',
|
|
99
|
+
'SessionEnd',
|
|
100
|
+
'UserPromptSubmit',
|
|
101
|
+
// Subagent tracking
|
|
102
|
+
'SubagentStart',
|
|
103
|
+
'SubagentStop',
|
|
104
|
+
// Context management
|
|
105
|
+
'PreCompact',
|
|
106
|
+
'PostCompact',
|
|
107
|
+
// File & directory changes
|
|
108
|
+
'FileChanged',
|
|
109
|
+
'CwdChanged',
|
|
110
|
+
// Notifications
|
|
111
|
+
'Notification',
|
|
112
|
+
'TeammateIdle',
|
|
113
|
+
// Worktree management (only Create/Remove — *Failed variants don't exist in CC, see #1002)
|
|
114
|
+
'WorktreeCreate',
|
|
115
|
+
'WorktreeRemove',
|
|
116
|
+
// Elicitation
|
|
117
|
+
'Elicitation',
|
|
118
|
+
'ElicitationResult',
|
|
119
|
+
];
|
|
120
|
+
export { HTTP_HOOK_EVENTS };
|
|
121
|
+
/**
|
|
122
|
+
* Generate the hooks section of a CC settings.json for a given session.
|
|
123
|
+
*
|
|
124
|
+
* @param baseUrl - Aegis base URL (e.g. "http://localhost:9100")
|
|
125
|
+
* @param sessionId - Aegis session ID (used as query param for routing)
|
|
126
|
+
* @param hookSecret - Per-session secret for hook URL authentication (Issue #629)
|
|
127
|
+
*/
|
|
128
|
+
export function generateHookSettings(baseUrl, sessionId, hookSecret) {
|
|
129
|
+
const hooks = {};
|
|
130
|
+
const callbackBaseUrl = normalizeHookBaseUrl(baseUrl);
|
|
131
|
+
for (const event of HTTP_HOOK_EVENTS) {
|
|
132
|
+
const hookConfig = {
|
|
133
|
+
type: 'http',
|
|
134
|
+
url: `${callbackBaseUrl}/v1/hooks/${event}?sessionId=${sessionId}`,
|
|
135
|
+
};
|
|
136
|
+
if (hookSecret) {
|
|
137
|
+
hookConfig.headers = { 'X-Hook-Secret': hookSecret };
|
|
138
|
+
}
|
|
139
|
+
hooks[event] = [
|
|
140
|
+
{
|
|
141
|
+
hooks: [hookConfig],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
return { hooks };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Write hook settings to a temporary file and return its path.
|
|
149
|
+
*
|
|
150
|
+
* Issue #339: Reads .claude/settings.local.json from workDir and deep-merges
|
|
151
|
+
* hook settings into it, so CC gets both project settings (env vars, permissions,
|
|
152
|
+
* bypassPermissions) AND Aegis hooks in a single --settings file.
|
|
153
|
+
*
|
|
154
|
+
* @param baseUrl - Aegis base URL
|
|
155
|
+
* @param sessionId - Aegis session ID
|
|
156
|
+
* @param workDir - Project working directory (to read settings.local.json from)
|
|
157
|
+
* @returns Path to the temporary settings file
|
|
158
|
+
*/
|
|
159
|
+
export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, workDir) {
|
|
160
|
+
const hookSettings = generateHookSettings(baseUrl, sessionId, hookSecret);
|
|
161
|
+
// Issue #339: Read project's settings.local.json and merge hooks into it.
|
|
162
|
+
// This ensures CC gets env vars, permissions, and bypassPermissions alongside hooks.
|
|
163
|
+
// Issue #847: Validate workDir path to prevent traversal attacks.
|
|
164
|
+
let merged = {};
|
|
165
|
+
const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
|
|
166
|
+
if (safeWorkDir) {
|
|
167
|
+
const projectSettingsPath = buildProjectSettingsPath(safeWorkDir);
|
|
168
|
+
if (existsSync(projectSettingsPath)) {
|
|
169
|
+
try {
|
|
170
|
+
const raw = await readFile(projectSettingsPath, 'utf-8');
|
|
171
|
+
merged = parseSettingsWithFallback(raw) ?? {};
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Malformed settings file — use empty base, hooks will still work
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Deep-merge: project settings as base, hooks merged by event key so both
|
|
179
|
+
// project-level and Aegis hooks coexist (Issue #635).
|
|
180
|
+
const existingHooks = merged.hooks ?? {};
|
|
181
|
+
const mergedHooks = { ...existingHooks };
|
|
182
|
+
for (const [event, entries] of Object.entries(hookSettings.hooks)) {
|
|
183
|
+
mergedHooks[event] = [...(existingHooks[event] ?? []), ...entries];
|
|
184
|
+
}
|
|
185
|
+
const combined = { ...merged, hooks: mergedHooks };
|
|
186
|
+
// Issue #931: Always inject MCP_CONNECTION_NONBLOCKING so CC does not block
|
|
187
|
+
// on MCP server connections when launched via Aegis orchestration.
|
|
188
|
+
(combined.env = (combined.env || {}));
|
|
189
|
+
(combined.env || {})["MCP_CONNECTION_NONBLOCKING"] = "true";
|
|
190
|
+
// Issue #648: Use unpredictable directory name and restrictive permissions
|
|
191
|
+
// to prevent symlink attacks and information disclosure in /tmp.
|
|
192
|
+
const suffix = randomBytes(4).toString('hex');
|
|
193
|
+
const settingsDir = join(tmpdir(), `aegis-hooks-${suffix}`);
|
|
194
|
+
await mkdir(settingsDir, { recursive: true, mode: 0o700 });
|
|
195
|
+
const filePath = join(settingsDir, `hooks-${sessionId}.json`);
|
|
196
|
+
await writeFile(filePath, JSON.stringify(combined, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
197
|
+
await secureFilePermissions(filePath);
|
|
198
|
+
return filePath;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Clean up a hook settings temp file.
|
|
202
|
+
*
|
|
203
|
+
* @param filePath - Path to the temporary settings file
|
|
204
|
+
*/
|
|
205
|
+
export async function cleanupHookSettingsFile(filePath) {
|
|
206
|
+
try {
|
|
207
|
+
if (existsSync(filePath)) {
|
|
208
|
+
await unlink(filePath);
|
|
209
|
+
// Issue #648: Also remove the randomized parent directory
|
|
210
|
+
const parentDir = join(filePath, '..');
|
|
211
|
+
await rmdir(parentDir).catch(() => {
|
|
212
|
+
// Non-fatal: directory may not be empty or already removed
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Non-fatal: temp file cleanup failed
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Issue #936: Clean stale session hooks from settings.local.json before writing new hooks.
|
|
222
|
+
*
|
|
223
|
+
* When sessions die, their hook URLs remain in settings.local.json.
|
|
224
|
+
* On restart, CC loads these dead hooks and crashes.
|
|
225
|
+
*
|
|
226
|
+
* @param workDir - Project working directory
|
|
227
|
+
* @param activeSessionIds - Set of currently active session IDs
|
|
228
|
+
*/
|
|
229
|
+
export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
|
|
230
|
+
const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
|
|
231
|
+
if (!safeWorkDir)
|
|
232
|
+
return;
|
|
233
|
+
const projectSettingsPath = buildProjectSettingsPath(safeWorkDir);
|
|
234
|
+
if (!existsSync(projectSettingsPath))
|
|
235
|
+
return;
|
|
236
|
+
try {
|
|
237
|
+
const raw = await readFile(projectSettingsPath, 'utf-8');
|
|
238
|
+
const parsed = ccSettingsSchema.safeParse(JSON.parse(raw));
|
|
239
|
+
if (!parsed.success)
|
|
240
|
+
return;
|
|
241
|
+
const settings = parsed.data;
|
|
242
|
+
const hooks = settings.hooks;
|
|
243
|
+
if (!hooks)
|
|
244
|
+
return;
|
|
245
|
+
let changed = false;
|
|
246
|
+
for (const [event, eventHooks] of Object.entries(hooks)) {
|
|
247
|
+
const filtered = eventHooks.filter(entry => {
|
|
248
|
+
const httpHook = entry.hooks?.find(h => h.type === 'http');
|
|
249
|
+
if (!httpHook)
|
|
250
|
+
return true;
|
|
251
|
+
const url = httpHook.url;
|
|
252
|
+
const match = url.match(/[?&]sessionId=([^&]+)/);
|
|
253
|
+
if (!match)
|
|
254
|
+
return true;
|
|
255
|
+
const sessionId = match[1];
|
|
256
|
+
if (!activeSessionIds.has(sessionId)) {
|
|
257
|
+
changed = true;
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
});
|
|
262
|
+
hooks[event] = filtered;
|
|
263
|
+
}
|
|
264
|
+
if (changed) {
|
|
265
|
+
await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
266
|
+
await secureFilePermissions(projectSettingsPath);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Non-fatal: cleanup failed
|
|
271
|
+
}
|
|
272
|
+
}
|
package/dist/hook.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hook.ts — Claude Code SessionStart hook for Aegis.
|
|
4
|
+
*
|
|
5
|
+
* Writes session_id → window_id mapping to ~/.aegis/session_map.json.
|
|
6
|
+
* Falls back to ~/.manus/ for backward compatibility.
|
|
7
|
+
* Called by CC's hook system, reads payload from stdin.
|
|
8
|
+
*
|
|
9
|
+
* Install: add to ~/.claude/settings.json:
|
|
10
|
+
* {
|
|
11
|
+
* "hooks": {
|
|
12
|
+
* "SessionStart": [{
|
|
13
|
+
* "hooks": [{ "type": "command", "command": "node /path/to/dist/hook.js", "timeout": 5 }]
|
|
14
|
+
* }]
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
/** Build a shell-safe command string that invokes hook.js with an explicit Node executable. */
|
|
19
|
+
export declare function buildHookCommand(scriptPath: string, nodeExecutable?: string, platform?: NodeJS.Platform): string;
|