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,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* path-utils.ts — path helpers shared across session/tmux logic.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Compute the Claude project hash folder from a workDir path.
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* - /home/user/project -> -home-user-project
|
|
9
|
+
* - D:\\Users\\me\\project -> -d-Users-me-project
|
|
10
|
+
*/
|
|
11
|
+
export declare function computeProjectHash(workDir: string): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* path-utils.ts — path helpers shared across session/tmux logic.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Compute the Claude project hash folder from a workDir path.
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* - /home/user/project -> -home-user-project
|
|
9
|
+
* - D:\\Users\\me\\project -> -d-Users-me-project
|
|
10
|
+
*/
|
|
11
|
+
export function computeProjectHash(workDir) {
|
|
12
|
+
const normalized = workDir.replace(/\\/g, '/').trim();
|
|
13
|
+
const withLowerDrive = normalized.replace(/^[A-Za-z]:/, (m) => `${m[0].toLowerCase()}`);
|
|
14
|
+
const segments = withLowerDrive
|
|
15
|
+
.split('/')
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map((segment) => segment.replace(/:/g, '').replace(/\s+/g, '-'));
|
|
18
|
+
if (segments.length === 0)
|
|
19
|
+
return '-';
|
|
20
|
+
return `-${segments.join('-')}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PermissionProfile } from './validation.js';
|
|
2
|
+
export interface PermissionEvaluationInput {
|
|
3
|
+
toolName: string;
|
|
4
|
+
toolInput?: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface PermissionEvaluationResult {
|
|
7
|
+
behavior: 'allow' | 'deny' | 'ask';
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function evaluatePermissionProfile(profile: PermissionProfile, input: PermissionEvaluationInput): PermissionEvaluationResult;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function globToRegExp(pattern) {
|
|
2
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
3
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
4
|
+
}
|
|
5
|
+
function extractCandidatePaths(toolInput) {
|
|
6
|
+
if (!toolInput)
|
|
7
|
+
return [];
|
|
8
|
+
const values = [toolInput.path, toolInput.file_path, toolInput.target, ...(Array.isArray(toolInput.paths) ? toolInput.paths : [])];
|
|
9
|
+
return values.filter((v) => typeof v === 'string');
|
|
10
|
+
}
|
|
11
|
+
function extractContentSize(toolInput) {
|
|
12
|
+
const content = toolInput?.content;
|
|
13
|
+
return typeof content === 'string' ? content.length : null;
|
|
14
|
+
}
|
|
15
|
+
function isLikelyWriteTool(toolName) {
|
|
16
|
+
return /write|edit|delete|rename|move|create/i.test(toolName);
|
|
17
|
+
}
|
|
18
|
+
export function evaluatePermissionProfile(profile, input) {
|
|
19
|
+
for (const rule of profile.rules) {
|
|
20
|
+
if (rule.tool !== input.toolName)
|
|
21
|
+
continue;
|
|
22
|
+
if (rule.pattern) {
|
|
23
|
+
const candidate = typeof input.toolInput?.command === 'string'
|
|
24
|
+
? input.toolInput.command
|
|
25
|
+
: JSON.stringify(input.toolInput ?? {});
|
|
26
|
+
if (!globToRegExp(rule.pattern).test(candidate))
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (rule.constraints?.readOnly && isLikelyWriteTool(input.toolName)) {
|
|
30
|
+
return { behavior: 'deny', reason: `Denied by readOnly constraint for ${input.toolName}` };
|
|
31
|
+
}
|
|
32
|
+
if (rule.constraints?.paths && rule.constraints.paths.length > 0) {
|
|
33
|
+
const paths = extractCandidatePaths(input.toolInput);
|
|
34
|
+
const allowed = paths.every((candidate) => rule.constraints.paths.some((prefix) => candidate.startsWith(prefix)));
|
|
35
|
+
if (!allowed) {
|
|
36
|
+
return { behavior: 'deny', reason: `Denied by path constraint for ${input.toolName}` };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (rule.constraints?.maxFileSize) {
|
|
40
|
+
const size = extractContentSize(input.toolInput);
|
|
41
|
+
if (size !== null && size > rule.constraints.maxFileSize) {
|
|
42
|
+
return { behavior: 'deny', reason: `Denied by maxFileSize constraint for ${input.toolName}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { behavior: rule.behavior, reason: `Matched rule for ${input.toolName}` };
|
|
46
|
+
}
|
|
47
|
+
return { behavior: profile.defaultBehavior, reason: 'No matching permission rule' };
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission-guard.ts — Guard against settings overriding CLI permission mode.
|
|
3
|
+
*
|
|
4
|
+
* Problem: Claude Code checks settings in 3 locations (priority order):
|
|
5
|
+
* 1. ~/.claude/settings.json (user-level)
|
|
6
|
+
* 2. <project>/.claude/settings.json (project-level, committed)
|
|
7
|
+
* 3. <project>/.claude/settings.local.json (project-level, local)
|
|
8
|
+
*
|
|
9
|
+
* Any of these can set `permissions.defaultMode: "bypassPermissions"` which
|
|
10
|
+
* OVERRIDES the CLI `--permission-mode default` flag. When Aegis spawns a
|
|
11
|
+
* session with autoApprove: false, the user expects permission prompts — but
|
|
12
|
+
* the settings silently bypass them.
|
|
13
|
+
*
|
|
14
|
+
* Fix: Before launching CC, if autoApprove is false, we neutralize any
|
|
15
|
+
* `bypassPermissions` in ALL 3 settings files by backing them up and patching
|
|
16
|
+
* the permission mode. On session cleanup we restore them.
|
|
17
|
+
*
|
|
18
|
+
* Issue #102 safety: Backups are stored in ~/.aegis/permission-backups/
|
|
19
|
+
* instead of the project directory to prevent accidental commit of secrets.
|
|
20
|
+
*/
|
|
21
|
+
/** Location 3: project-level local settings */
|
|
22
|
+
export declare function settingsPath(workDir: string): string;
|
|
23
|
+
/** Location 2: project-level committed settings */
|
|
24
|
+
export declare function projectSettingsPath(workDir: string): string;
|
|
25
|
+
/** Location 1: user-level settings. Accepts optional homeDir for test isolation. */
|
|
26
|
+
export declare function userSettingsPath(homeDir?: string): string;
|
|
27
|
+
/** Backup directory for a given workDir. Accepts optional homeDir for test isolation. */
|
|
28
|
+
export declare function backupDirForWorkDir(workDir: string, homeDir?: string): string;
|
|
29
|
+
/** Get backup path for settings.local.json. Accepts optional homeDir for test isolation. */
|
|
30
|
+
export declare function backupPath(workDir: string, homeDir?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Check all 3 CC settings locations for bypassPermissions. Back up and patch
|
|
33
|
+
* any that have it. Returns true if ANY file was patched.
|
|
34
|
+
*
|
|
35
|
+
* @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
|
|
36
|
+
*/
|
|
37
|
+
export declare function neutralizeBypassPermissions(workDir: string, targetMode?: string, homeDir?: string): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Restore all 3 settings files from backups.
|
|
40
|
+
* Checks new backup locations first, then legacy location for backward compat.
|
|
41
|
+
*
|
|
42
|
+
* @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
|
|
43
|
+
*/
|
|
44
|
+
export declare function restoreSettings(workDir: string, homeDir?: string): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Clean up any orphaned backups (e.g. from a crash).
|
|
47
|
+
* Restores all 3 settings files from their backups.
|
|
48
|
+
*
|
|
49
|
+
* @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
|
|
50
|
+
*/
|
|
51
|
+
export declare function cleanOrphanedBackup(workDir: string, homeDir?: string): Promise<void>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission-guard.ts — Guard against settings overriding CLI permission mode.
|
|
3
|
+
*
|
|
4
|
+
* Problem: Claude Code checks settings in 3 locations (priority order):
|
|
5
|
+
* 1. ~/.claude/settings.json (user-level)
|
|
6
|
+
* 2. <project>/.claude/settings.json (project-level, committed)
|
|
7
|
+
* 3. <project>/.claude/settings.local.json (project-level, local)
|
|
8
|
+
*
|
|
9
|
+
* Any of these can set `permissions.defaultMode: "bypassPermissions"` which
|
|
10
|
+
* OVERRIDES the CLI `--permission-mode default` flag. When Aegis spawns a
|
|
11
|
+
* session with autoApprove: false, the user expects permission prompts — but
|
|
12
|
+
* the settings silently bypass them.
|
|
13
|
+
*
|
|
14
|
+
* Fix: Before launching CC, if autoApprove is false, we neutralize any
|
|
15
|
+
* `bypassPermissions` in ALL 3 settings files by backing them up and patching
|
|
16
|
+
* the permission mode. On session cleanup we restore them.
|
|
17
|
+
*
|
|
18
|
+
* Issue #102 safety: Backups are stored in ~/.aegis/permission-backups/
|
|
19
|
+
* instead of the project directory to prevent accidental commit of secrets.
|
|
20
|
+
*/
|
|
21
|
+
import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
22
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { ccSettingsSchema } from './validation.js';
|
|
27
|
+
const SETTINGS_DIR = '.claude';
|
|
28
|
+
const LOCAL_SETTINGS_FILE = 'settings.local.json';
|
|
29
|
+
const PROJECT_SETTINGS_FILE = 'settings.json';
|
|
30
|
+
/** Hash a workDir path to create a safe, unique backup directory name. */
|
|
31
|
+
function workDirHash(workDir) {
|
|
32
|
+
return createHash('sha256').update(workDir).digest('hex').slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
// ─── Path helpers ───
|
|
35
|
+
/** Location 3: project-level local settings */
|
|
36
|
+
export function settingsPath(workDir) {
|
|
37
|
+
return join(workDir, SETTINGS_DIR, LOCAL_SETTINGS_FILE);
|
|
38
|
+
}
|
|
39
|
+
/** Location 2: project-level committed settings */
|
|
40
|
+
export function projectSettingsPath(workDir) {
|
|
41
|
+
return join(workDir, SETTINGS_DIR, PROJECT_SETTINGS_FILE);
|
|
42
|
+
}
|
|
43
|
+
/** Location 1: user-level settings. Accepts optional homeDir for test isolation. */
|
|
44
|
+
export function userSettingsPath(homeDir) {
|
|
45
|
+
return join(homeDir ?? homedir(), SETTINGS_DIR, PROJECT_SETTINGS_FILE);
|
|
46
|
+
}
|
|
47
|
+
/** Backup directory for a given workDir. Accepts optional homeDir for test isolation. */
|
|
48
|
+
export function backupDirForWorkDir(workDir, homeDir) {
|
|
49
|
+
const aegisDir = join(homeDir ?? homedir(), '.aegis');
|
|
50
|
+
return join(aegisDir, 'permission-backups', workDirHash(workDir));
|
|
51
|
+
}
|
|
52
|
+
/** Get backup path for settings.local.json. Accepts optional homeDir for test isolation. */
|
|
53
|
+
export function backupPath(workDir, homeDir) {
|
|
54
|
+
return join(backupDirForWorkDir(workDir, homeDir), LOCAL_SETTINGS_FILE);
|
|
55
|
+
}
|
|
56
|
+
/** Legacy backup path (in project dir) — for migration/cleanup. */
|
|
57
|
+
function legacyBackupPath(workDir) {
|
|
58
|
+
return settingsPath(workDir) + '.aegis-backup';
|
|
59
|
+
}
|
|
60
|
+
/** User-level backup directory. */
|
|
61
|
+
function userBackupDir(homeDir) {
|
|
62
|
+
return join(homeDir, '.aegis', 'permission-backups', '_user_');
|
|
63
|
+
}
|
|
64
|
+
/** All settings locations that could contain bypassPermissions. */
|
|
65
|
+
function getAllSettingsLocations(workDir, homeDir) {
|
|
66
|
+
return [
|
|
67
|
+
// Location 1: user-level
|
|
68
|
+
{ filePath: userSettingsPath(homeDir), backupPath: join(userBackupDir(homeDir), PROJECT_SETTINGS_FILE) },
|
|
69
|
+
// Location 2: project-level committed
|
|
70
|
+
{ filePath: projectSettingsPath(workDir), backupPath: join(backupDirForWorkDir(workDir, homeDir), PROJECT_SETTINGS_FILE) },
|
|
71
|
+
// Location 3: project-level local
|
|
72
|
+
{ filePath: settingsPath(workDir), backupPath: backupPath(workDir, homeDir) },
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Check a single settings file for bypassPermissions, back it up, and patch it.
|
|
77
|
+
* Returns true if the file was patched.
|
|
78
|
+
*/
|
|
79
|
+
async function neutralizeOneFile(filePath, bpPath, targetMode) {
|
|
80
|
+
if (!existsSync(filePath))
|
|
81
|
+
return false;
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
84
|
+
const settingsParsed = ccSettingsSchema.safeParse(JSON.parse(raw));
|
|
85
|
+
if (!settingsParsed.success)
|
|
86
|
+
return false;
|
|
87
|
+
const settings = settingsParsed.data;
|
|
88
|
+
const mode = settings?.permissions?.defaultMode;
|
|
89
|
+
if (mode !== 'bypassPermissions')
|
|
90
|
+
return false;
|
|
91
|
+
// Back up
|
|
92
|
+
mkdirSync(join(bpPath, '..'), { recursive: true });
|
|
93
|
+
await writeFile(bpPath, raw);
|
|
94
|
+
// Patch
|
|
95
|
+
if (!settings.permissions)
|
|
96
|
+
settings.permissions = {};
|
|
97
|
+
settings.permissions.defaultMode = targetMode;
|
|
98
|
+
await writeFile(filePath, JSON.stringify(settings, null, 2) + '\n');
|
|
99
|
+
console.log(`Permission guard: neutralized bypassPermissions in ${filePath} (backup: ${bpPath})`);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
console.error(`Permission guard: failed to neutralize ${filePath}: ${e.message}`);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Check all 3 CC settings locations for bypassPermissions. Back up and patch
|
|
109
|
+
* any that have it. Returns true if ANY file was patched.
|
|
110
|
+
*
|
|
111
|
+
* @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
|
|
112
|
+
*/
|
|
113
|
+
export async function neutralizeBypassPermissions(workDir, targetMode = 'default', homeDir) {
|
|
114
|
+
const locations = getAllSettingsLocations(workDir, homeDir ?? homedir());
|
|
115
|
+
let anyPatched = false;
|
|
116
|
+
for (const loc of locations) {
|
|
117
|
+
const patched = await neutralizeOneFile(loc.filePath, loc.backupPath, targetMode);
|
|
118
|
+
if (patched)
|
|
119
|
+
anyPatched = true;
|
|
120
|
+
}
|
|
121
|
+
return anyPatched;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Restore a single settings file from its backup.
|
|
125
|
+
*/
|
|
126
|
+
async function restoreOneFile(filePath, bpPath) {
|
|
127
|
+
if (!existsSync(bpPath))
|
|
128
|
+
return false;
|
|
129
|
+
try {
|
|
130
|
+
const raw = await readFile(bpPath, 'utf-8');
|
|
131
|
+
await writeFile(filePath, raw);
|
|
132
|
+
await unlink(bpPath);
|
|
133
|
+
console.log(`Permission guard: restored ${filePath} from backup`);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
console.error(`Permission guard: failed to restore from ${bpPath}: ${e.message}`);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Restore all 3 settings files from backups.
|
|
143
|
+
* Checks new backup locations first, then legacy location for backward compat.
|
|
144
|
+
*
|
|
145
|
+
* @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
|
|
146
|
+
*/
|
|
147
|
+
export async function restoreSettings(workDir, homeDir) {
|
|
148
|
+
const locations = getAllSettingsLocations(workDir, homeDir ?? homedir());
|
|
149
|
+
for (const loc of locations) {
|
|
150
|
+
await restoreOneFile(loc.filePath, loc.backupPath);
|
|
151
|
+
}
|
|
152
|
+
// Legacy fallback for settings.local.json only (in project dir)
|
|
153
|
+
const legacy = legacyBackupPath(workDir);
|
|
154
|
+
if (existsSync(legacy)) {
|
|
155
|
+
try {
|
|
156
|
+
await rename(legacy, settingsPath(workDir));
|
|
157
|
+
console.log(`Permission guard: restored ${settingsPath(workDir)} from legacy backup`);
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
console.error(`Permission guard: failed to restore from legacy ${legacy}: ${e.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Clean up any orphaned backups (e.g. from a crash).
|
|
166
|
+
* Restores all 3 settings files from their backups.
|
|
167
|
+
*
|
|
168
|
+
* @param homeDir - Override home directory (for test isolation). Defaults to os.homedir().
|
|
169
|
+
*/
|
|
170
|
+
export async function cleanOrphanedBackup(workDir, homeDir) {
|
|
171
|
+
const locations = getAllSettingsLocations(workDir, homeDir ?? homedir());
|
|
172
|
+
for (const loc of locations) {
|
|
173
|
+
if (!existsSync(loc.backupPath))
|
|
174
|
+
continue;
|
|
175
|
+
try {
|
|
176
|
+
const raw = await readFile(loc.backupPath, 'utf-8');
|
|
177
|
+
await writeFile(loc.filePath, raw);
|
|
178
|
+
await unlink(loc.backupPath);
|
|
179
|
+
console.log(`Permission guard: cleaned orphaned backup for ${loc.filePath}`);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
console.error(`Permission guard: failed to clean orphaned backup: ${e.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Clean legacy backup
|
|
186
|
+
const legacy = legacyBackupPath(workDir);
|
|
187
|
+
if (existsSync(legacy)) {
|
|
188
|
+
try {
|
|
189
|
+
await rename(legacy, settingsPath(workDir));
|
|
190
|
+
console.log(`Permission guard: cleaned legacy orphaned backup in ${workDir}`);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
console.error(`Permission guard: failed to clean legacy orphaned backup: ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type PermissionDecision = 'allow' | 'deny';
|
|
2
|
+
export declare class PermissionRequestManager {
|
|
3
|
+
private pendingPermissions;
|
|
4
|
+
waitForPermissionDecision(sessionId: string, timeoutMs?: number, toolName?: string, prompt?: string): Promise<PermissionDecision>;
|
|
5
|
+
hasPendingPermission(sessionId: string): boolean;
|
|
6
|
+
getPendingPermissionInfo(sessionId: string): {
|
|
7
|
+
toolName?: string;
|
|
8
|
+
prompt?: string;
|
|
9
|
+
} | null;
|
|
10
|
+
resolvePendingPermission(sessionId: string, decision: PermissionDecision): boolean;
|
|
11
|
+
cleanupPendingPermission(sessionId: string): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class PermissionRequestManager {
|
|
2
|
+
pendingPermissions = new Map();
|
|
3
|
+
waitForPermissionDecision(sessionId, timeoutMs = 10_000, toolName, prompt) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const timer = setTimeout(() => {
|
|
6
|
+
this.pendingPermissions.delete(sessionId);
|
|
7
|
+
console.log(`Hooks: PermissionRequest timeout for session ${sessionId} - auto-rejecting`);
|
|
8
|
+
resolve('deny');
|
|
9
|
+
}, timeoutMs);
|
|
10
|
+
this.pendingPermissions.set(sessionId, { resolve, timer, toolName, prompt });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
hasPendingPermission(sessionId) {
|
|
14
|
+
return this.pendingPermissions.has(sessionId);
|
|
15
|
+
}
|
|
16
|
+
getPendingPermissionInfo(sessionId) {
|
|
17
|
+
const pending = this.pendingPermissions.get(sessionId);
|
|
18
|
+
return pending ? { toolName: pending.toolName, prompt: pending.prompt } : null;
|
|
19
|
+
}
|
|
20
|
+
resolvePendingPermission(sessionId, decision) {
|
|
21
|
+
const pending = this.pendingPermissions.get(sessionId);
|
|
22
|
+
if (!pending)
|
|
23
|
+
return false;
|
|
24
|
+
clearTimeout(pending.timer);
|
|
25
|
+
this.pendingPermissions.delete(sessionId);
|
|
26
|
+
pending.resolve(decision);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
cleanupPendingPermission(sessionId) {
|
|
30
|
+
const pending = this.pendingPermissions.get(sessionId);
|
|
31
|
+
if (pending) {
|
|
32
|
+
clearTimeout(pending.timer);
|
|
33
|
+
this.pendingPermissions.delete(sessionId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { SessionManager } from './session.js';
|
|
3
|
+
import type { MetricsCollector } from './metrics.js';
|
|
4
|
+
type PermissionSessions = Pick<SessionManager, 'approve' | 'reject' | 'getLatencyMetrics'>;
|
|
5
|
+
type PermissionMetrics = Pick<MetricsCollector, 'recordPermissionResponse'>;
|
|
6
|
+
export declare function registerPermissionRoutes(app: FastifyInstance, sessions: PermissionSessions, metrics: PermissionMetrics): void;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function createPermissionHandler(action, sessions, metrics) {
|
|
2
|
+
return async (req, reply) => {
|
|
3
|
+
try {
|
|
4
|
+
if (action === 'approve') {
|
|
5
|
+
await sessions.approve(req.params.id);
|
|
6
|
+
}
|
|
7
|
+
else {
|
|
8
|
+
await sessions.reject(req.params.id);
|
|
9
|
+
}
|
|
10
|
+
// Issue #87: Record permission response latency.
|
|
11
|
+
const lat = sessions.getLatencyMetrics(req.params.id);
|
|
12
|
+
if (lat !== null && lat.permission_response_ms !== null) {
|
|
13
|
+
metrics.recordPermissionResponse(req.params.id, lat.permission_response_ms);
|
|
14
|
+
}
|
|
15
|
+
return { ok: true };
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
return reply.status(404).send({ error: e instanceof Error ? e.message : String(e) });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function registerPermissionRoutes(app, sessions, metrics) {
|
|
23
|
+
for (const action of ['approve', 'reject']) {
|
|
24
|
+
const handler = createPermissionHandler(action, sessions, metrics);
|
|
25
|
+
app.post(`/v1/sessions/:id/${action}`, handler);
|
|
26
|
+
app.post(`/sessions/:id/${action}`, handler);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline.ts — Batch create and pipeline orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Issue #36: Create multiple sessions in parallel, or define
|
|
5
|
+
* sequential pipelines with stage dependencies.
|
|
6
|
+
*/
|
|
7
|
+
import { type SessionManager } from './session.js';
|
|
8
|
+
import { type SessionEventBus } from './events.js';
|
|
9
|
+
export interface BatchSessionSpec {
|
|
10
|
+
name?: string;
|
|
11
|
+
workDir: string;
|
|
12
|
+
prompt?: string;
|
|
13
|
+
permissionMode?: string;
|
|
14
|
+
/** @deprecated Use permissionMode instead. */
|
|
15
|
+
autoApprove?: boolean;
|
|
16
|
+
stallThresholdMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface BatchResult {
|
|
19
|
+
sessions: Array<{
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
promptDelivery?: {
|
|
23
|
+
delivered: boolean;
|
|
24
|
+
attempts: number;
|
|
25
|
+
};
|
|
26
|
+
}>;
|
|
27
|
+
created: number;
|
|
28
|
+
failed: number;
|
|
29
|
+
errors: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface PipelineStage {
|
|
32
|
+
name: string;
|
|
33
|
+
workDir?: string;
|
|
34
|
+
prompt: string;
|
|
35
|
+
dependsOn?: string[];
|
|
36
|
+
permissionMode?: string;
|
|
37
|
+
/** @deprecated Use permissionMode instead. */
|
|
38
|
+
autoApprove?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface PipelineConfig {
|
|
41
|
+
name: string;
|
|
42
|
+
workDir: string;
|
|
43
|
+
stages: PipelineStage[];
|
|
44
|
+
}
|
|
45
|
+
export type PipelineStageStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
46
|
+
export interface PipelineState {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
currentStage: 'plan' | 'execute' | 'verify' | 'fix' | 'submit' | 'done';
|
|
50
|
+
status: 'running' | 'completed' | 'failed';
|
|
51
|
+
retryCount: number;
|
|
52
|
+
maxRetries: number;
|
|
53
|
+
stageHistory: Array<{
|
|
54
|
+
stage: string;
|
|
55
|
+
enteredAt: number;
|
|
56
|
+
exitedAt?: number;
|
|
57
|
+
output?: unknown;
|
|
58
|
+
}>;
|
|
59
|
+
stages: Array<{
|
|
60
|
+
name: string;
|
|
61
|
+
status: PipelineStageStatus;
|
|
62
|
+
sessionId?: string;
|
|
63
|
+
dependsOn: string[];
|
|
64
|
+
startedAt?: number;
|
|
65
|
+
completedAt?: number;
|
|
66
|
+
error?: string;
|
|
67
|
+
}>;
|
|
68
|
+
createdAt: number;
|
|
69
|
+
}
|
|
70
|
+
export declare class PipelineManager {
|
|
71
|
+
private sessions;
|
|
72
|
+
private eventBus?;
|
|
73
|
+
private static readonly PIPELINE_RETRY_MAX_ATTEMPTS;
|
|
74
|
+
private static readonly PIPELINE_FIX_MAX_RETRIES;
|
|
75
|
+
private pipelines;
|
|
76
|
+
private pipelineConfigs;
|
|
77
|
+
private pollInterval;
|
|
78
|
+
private cleanupTimers;
|
|
79
|
+
constructor(sessions: SessionManager, eventBus?: SessionEventBus | undefined);
|
|
80
|
+
/** Create multiple sessions in parallel. */
|
|
81
|
+
batchCreate(specs: BatchSessionSpec[]): Promise<BatchResult>;
|
|
82
|
+
/** Create a pipeline with stage dependencies. */
|
|
83
|
+
createPipeline(config: PipelineConfig): Promise<PipelineState>;
|
|
84
|
+
/** Get pipeline state. */
|
|
85
|
+
getPipeline(id: string): PipelineState | null;
|
|
86
|
+
/** List all pipelines. */
|
|
87
|
+
listPipelines(): PipelineState[];
|
|
88
|
+
/** Advance a pipeline: start stages whose dependencies are met. */
|
|
89
|
+
private advancePipeline;
|
|
90
|
+
/** Poll running pipelines and advance stages. */
|
|
91
|
+
private pollPipelines;
|
|
92
|
+
private transitionPipelineStage;
|
|
93
|
+
/** Detect circular dependencies. Throws if found. */
|
|
94
|
+
private detectCycles;
|
|
95
|
+
/** Clean up. */
|
|
96
|
+
destroy(): void;
|
|
97
|
+
}
|