@buildifyx/desktop-agent 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/README.md +337 -0
- package/package.json +32 -0
- package/src/audit/logger.js +30 -0
- package/src/cli/commands/cloud.js +89 -0
- package/src/cli/commands/doctor.js +18 -0
- package/src/cli/commands/login.js +59 -0
- package/src/cli/commands/logout.js +20 -0
- package/src/cli/commands/remote.js +64 -0
- package/src/cli/commands/status.js +32 -0
- package/src/cli/commands/update.js +31 -0
- package/src/cli/help.js +52 -0
- package/src/cli/main.js +55 -0
- package/src/cli/options.js +34 -0
- package/src/cli.js +20 -0
- package/src/core/dispatcher.js +49 -0
- package/src/core/errors.js +37 -0
- package/src/core/permissions.js +53 -0
- package/src/core/runtime.js +45 -0
- package/src/events/bus.js +39 -0
- package/src/permissions/approvals.js +47 -0
- package/src/permissions/controller.js +92 -0
- package/src/permissions/evaluator.js +93 -0
- package/src/permissions/manager.js +57 -0
- package/src/permissions/policy.js +28 -0
- package/src/permissions/store.js +27 -0
- package/src/security/path.js +61 -0
- package/src/security/scope.js +45 -0
- package/src/server.js +1 -0
- package/src/services/commands.js +107 -0
- package/src/services/files.js +104 -0
- package/src/services/index.js +11 -0
- package/src/services/system.js +17 -0
- package/src/transport/cloud.js +206 -0
- package/src/transport/mcp/manifest-store.js +32 -0
- package/src/transport/mcp/response.js +25 -0
- package/src/transport/mcp/server.js +114 -0
- package/src/transport/mcp/tools/commands.js +33 -0
- package/src/transport/mcp/tools/files.js +55 -0
- package/src/transport/mcp/tools/index.js +1 -0
- package/src/transport/mcp/tools/registry.js +92 -0
- package/src/transport/mcp/tools/system.js +16 -0
- package/src/tui/app.js +392 -0
- package/src/tui/commands.js +71 -0
- package/src/tui/index.js +37 -0
- package/src/tui/layout.js +48 -0
- package/src/tui/model.js +62 -0
- package/src/tui/profiles.js +32 -0
- package/src/utils/credentials.js +35 -0
- package/src/utils/text.js +180 -0
- package/src/version.js +111 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { checkForUpdate, getPackageMetadata } from '../../version.js';
|
|
4
|
+
|
|
5
|
+
function npmExecutable() {
|
|
6
|
+
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function runProcess(command, args, options = {}) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const child = execFile(command, args, { ...options, windowsHide: false }, (error) => {
|
|
12
|
+
if (error) return reject(error);
|
|
13
|
+
resolve();
|
|
14
|
+
});
|
|
15
|
+
child.stdout?.pipe(process.stdout);
|
|
16
|
+
child.stderr?.pipe(process.stderr);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runUpdate() {
|
|
21
|
+
const metadata = await getPackageMetadata();
|
|
22
|
+
const status = await checkForUpdate(metadata.name, metadata.version);
|
|
23
|
+
if (!status.updateAvailable) {
|
|
24
|
+
console.log(status.latestVersion ? `Already up to date (${metadata.version}).` : `Installed version: ${metadata.version}. Could not verify npm registry.`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log(`Updating ${metadata.name} to the latest version...`);
|
|
29
|
+
await runProcess(npmExecutable(), ['install', '--global', `${metadata.name}@latest`]);
|
|
30
|
+
console.log('Update complete. Run bdxa again to use the latest version.');
|
|
31
|
+
}
|
package/src/cli/help.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function printHelp() {
|
|
2
|
+
console.log(`bdxa — Buildifyx Desktop Agent
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
bdxa [options]
|
|
6
|
+
bdxa <command> [options]
|
|
7
|
+
|
|
8
|
+
Default:
|
|
9
|
+
Running bdxa connects this device to Buildifyx Cloud at bdxa.buildifyx.com.
|
|
10
|
+
Login once with a one-time token before connecting.
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
login Authenticate this device with a login token
|
|
14
|
+
logout Revoke this device credential and remove it locally
|
|
15
|
+
status Show device authentication status
|
|
16
|
+
connect Connect to Buildifyx Cloud (same as default bdxa)
|
|
17
|
+
local Start the legacy local MCP HTTP server for development
|
|
18
|
+
doctor, d Check environment, root access, and package version
|
|
19
|
+
update, u Update Buildifyx Desktop Agent from npm
|
|
20
|
+
help Show this help message
|
|
21
|
+
|
|
22
|
+
Global options:
|
|
23
|
+
-h, --help Show help
|
|
24
|
+
-v, --version Show installed version
|
|
25
|
+
|
|
26
|
+
Cloud options:
|
|
27
|
+
--root <path> Primary workspace root (default: current directory)
|
|
28
|
+
--full-access Permit arbitrary executable names; execution is not sandboxed
|
|
29
|
+
--no-tui Disable the interactive TUI; ASK returns confirmation-required
|
|
30
|
+
|
|
31
|
+
Login options:
|
|
32
|
+
--token <token> Login token (prefer interactive input to avoid shell history)
|
|
33
|
+
--cloud <url> Cloud origin (default: https://bdxa.buildifyx.com)
|
|
34
|
+
|
|
35
|
+
Local development options:
|
|
36
|
+
--root <path> Primary workspace root
|
|
37
|
+
--port <number> Local MCP port (default: 3333)
|
|
38
|
+
--full-access Permit arbitrary executable names
|
|
39
|
+
--no-tui Disable the interactive TUI
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
bdxa login
|
|
43
|
+
bdxa status
|
|
44
|
+
bdxa --root ~/projects
|
|
45
|
+
bdxa connect --root ~/projects
|
|
46
|
+
bdxa logout
|
|
47
|
+
bdxa local --root ~/projects --port 3333
|
|
48
|
+
bdxa doctor
|
|
49
|
+
bdxa update
|
|
50
|
+
bdxa -v
|
|
51
|
+
`);
|
|
52
|
+
}
|
package/src/cli/main.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { printHelp } from './help.js';
|
|
2
|
+
import { parseInvocation } from './options.js';
|
|
3
|
+
import { runCloud } from './commands/cloud.js';
|
|
4
|
+
import { runDoctor } from './commands/doctor.js';
|
|
5
|
+
import { runLogin } from './commands/login.js';
|
|
6
|
+
import { runLogout } from './commands/logout.js';
|
|
7
|
+
import { runRemote } from './commands/remote.js';
|
|
8
|
+
import { runStatus } from './commands/status.js';
|
|
9
|
+
import { runUpdate } from './commands/update.js';
|
|
10
|
+
import { getPackageMetadata } from '../version.js';
|
|
11
|
+
|
|
12
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
13
|
+
const { command, args } = parseInvocation(argv);
|
|
14
|
+
|
|
15
|
+
if (command === '-h' || command === '--help' || command === 'help') {
|
|
16
|
+
printHelp();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (command === '-v' || command === '--version') {
|
|
20
|
+
console.log((await getPackageMetadata()).version);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (command === 'update' || command === 'u') {
|
|
24
|
+
await runUpdate();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
switch (command) {
|
|
29
|
+
case 'cloud':
|
|
30
|
+
case 'connect':
|
|
31
|
+
await runCloud(args);
|
|
32
|
+
return;
|
|
33
|
+
case 'login':
|
|
34
|
+
await runLogin(args);
|
|
35
|
+
return;
|
|
36
|
+
case 'logout':
|
|
37
|
+
await runLogout();
|
|
38
|
+
return;
|
|
39
|
+
case 'status':
|
|
40
|
+
await runStatus();
|
|
41
|
+
return;
|
|
42
|
+
case 'local':
|
|
43
|
+
case 'remote':
|
|
44
|
+
case 'r':
|
|
45
|
+
await runRemote(args);
|
|
46
|
+
return;
|
|
47
|
+
case 'doctor':
|
|
48
|
+
case 'd':
|
|
49
|
+
await runDoctor(args);
|
|
50
|
+
return;
|
|
51
|
+
default:
|
|
52
|
+
printHelp();
|
|
53
|
+
throw new Error(`Unknown command: ${command}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { access, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
|
|
5
|
+
export function getOption(args, name, fallback) {
|
|
6
|
+
const index = args.indexOf(name);
|
|
7
|
+
if (index === -1) return fallback;
|
|
8
|
+
const value = args[index + 1];
|
|
9
|
+
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${name}`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hasFlag(args, name) {
|
|
14
|
+
return args.includes(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function resolveRoot(input = process.cwd()) {
|
|
18
|
+
const candidate = path.resolve(input);
|
|
19
|
+
await access(candidate);
|
|
20
|
+
return realpath(candidate);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parsePort(value = '3333') {
|
|
24
|
+
const port = Number(value);
|
|
25
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid port: ${value}`);
|
|
26
|
+
return port;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseInvocation(argv) {
|
|
30
|
+
const first = argv[0];
|
|
31
|
+
const cloudFlags = new Set(['--root', '--full-access', '--no-tui']);
|
|
32
|
+
if (!first || cloudFlags.has(first)) return { command: 'cloud', args: argv };
|
|
33
|
+
return { command: first, args: argv.slice(1) };
|
|
34
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { runCli } from './cli/main.js';
|
|
4
|
+
|
|
5
|
+
runCli().catch((error) => {
|
|
6
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7
|
+
console.error(`\nError: ${message}`);
|
|
8
|
+
|
|
9
|
+
if (error instanceof Error && error.cause) {
|
|
10
|
+
const cause = error.cause;
|
|
11
|
+
if (cause instanceof Error) {
|
|
12
|
+
const details = [cause.code, cause.message].filter(Boolean).join(': ');
|
|
13
|
+
if (details) console.error(`Cause: ${details}`);
|
|
14
|
+
} else {
|
|
15
|
+
console.error(`Cause: ${String(cause)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { AgentError, ErrorCode, normalizeError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
function summarizeInput(toolName, input = {}) {
|
|
5
|
+
const summary = { ...input };
|
|
6
|
+
if (typeof summary.content === 'string') summary.content = `<${summary.content.length} chars>`;
|
|
7
|
+
if (Array.isArray(summary.lines)) summary.lines = `<${summary.lines.length} lines>`;
|
|
8
|
+
return summary;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createDispatcher({ services, authorize, eventBus }) {
|
|
12
|
+
if (!services || typeof services !== 'object') {
|
|
13
|
+
throw new AgentError(ErrorCode.INTERNAL_ERROR, 'services are required');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return async function dispatch(toolName, input = {}) {
|
|
17
|
+
const requestId = randomUUID();
|
|
18
|
+
const startedAt = Date.now();
|
|
19
|
+
const handler = services[toolName];
|
|
20
|
+
|
|
21
|
+
eventBus?.emit('tool.started', {
|
|
22
|
+
requestId,
|
|
23
|
+
tool: toolName,
|
|
24
|
+
input: summarizeInput(toolName, input)
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (typeof handler !== 'function') {
|
|
28
|
+
const error = new AgentError(ErrorCode.TOOL_NOT_FOUND, `Unknown tool: ${toolName}`, { toolName });
|
|
29
|
+
eventBus?.emit('tool.failed', { requestId, tool: toolName, durationMs: Date.now() - startedAt, error: { code: error.code, message: error.message } });
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const context = authorize ? await authorize(toolName, input, requestId) : {};
|
|
35
|
+
const result = await handler(input, { ...context, requestId, eventBus });
|
|
36
|
+
eventBus?.emit('tool.completed', { requestId, tool: toolName, durationMs: Date.now() - startedAt });
|
|
37
|
+
return result;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const normalized = normalizeError(error);
|
|
40
|
+
eventBus?.emit('tool.failed', {
|
|
41
|
+
requestId,
|
|
42
|
+
tool: toolName,
|
|
43
|
+
durationMs: Date.now() - startedAt,
|
|
44
|
+
error: { code: normalized.code, message: normalized.message }
|
|
45
|
+
});
|
|
46
|
+
throw normalized;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const ErrorCode = Object.freeze({
|
|
2
|
+
TOOL_NOT_FOUND: 'TOOL_NOT_FOUND',
|
|
3
|
+
PERMISSION_DENIED: 'PERMISSION_DENIED',
|
|
4
|
+
CONFIRMATION_REQUIRED: 'CONFIRMATION_REQUIRED',
|
|
5
|
+
PATH_OUTSIDE_ROOT: 'PATH_OUTSIDE_ROOT',
|
|
6
|
+
GIT_INTERNAL_PATH: 'GIT_INTERNAL_PATH',
|
|
7
|
+
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
|
|
8
|
+
FILE_NOT_REGULAR: 'FILE_NOT_REGULAR',
|
|
9
|
+
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
|
|
10
|
+
INVALID_TEXT_FILE: 'INVALID_TEXT_FILE',
|
|
11
|
+
FILE_EXISTS: 'FILE_EXISTS',
|
|
12
|
+
COMMAND_NOT_ALLOWED: 'COMMAND_NOT_ALLOWED',
|
|
13
|
+
COMMAND_TIMEOUT: 'COMMAND_TIMEOUT',
|
|
14
|
+
INVALID_INPUT: 'INVALID_INPUT',
|
|
15
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR'
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export class AgentError extends Error {
|
|
19
|
+
constructor(code, message, details) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'AgentError';
|
|
22
|
+
this.code = code;
|
|
23
|
+
if (details !== undefined) this.details = details;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isAgentError(error) {
|
|
28
|
+
return error instanceof AgentError;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeError(error, fallbackCode = ErrorCode.INTERNAL_ERROR) {
|
|
32
|
+
if (isAgentError(error)) return error;
|
|
33
|
+
return new AgentError(
|
|
34
|
+
fallbackCode,
|
|
35
|
+
error instanceof Error ? error.message : String(error)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AgentError, ErrorCode } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const PermissionAction = Object.freeze({
|
|
4
|
+
ALLOW: 'allow',
|
|
5
|
+
DENY: 'deny',
|
|
6
|
+
CONFIRM: 'confirm'
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const ToolPermission = Object.freeze({
|
|
10
|
+
get_system_info: 'read',
|
|
11
|
+
list_directory: 'read',
|
|
12
|
+
read_file: 'read',
|
|
13
|
+
write_file: 'write',
|
|
14
|
+
edit_file: 'write',
|
|
15
|
+
run_command: 'command'
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_PERMISSION_POLICY = Object.freeze({
|
|
19
|
+
read: PermissionAction.ALLOW,
|
|
20
|
+
write: PermissionAction.ALLOW,
|
|
21
|
+
command: PermissionAction.ALLOW
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function createPermissionPolicy(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
...DEFAULT_PERMISSION_POLICY,
|
|
27
|
+
...overrides
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function assertToolPermission(toolName, policy = DEFAULT_PERMISSION_POLICY) {
|
|
32
|
+
const permission = ToolPermission[toolName];
|
|
33
|
+
if (!permission) {
|
|
34
|
+
throw new AgentError(ErrorCode.TOOL_NOT_FOUND, `Unknown tool: ${toolName}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const action = policy[permission] ?? PermissionAction.DENY;
|
|
38
|
+
if (action === PermissionAction.DENY) {
|
|
39
|
+
throw new AgentError(ErrorCode.PERMISSION_DENIED, `Permission denied for ${toolName} (${permission}).`, {
|
|
40
|
+
toolName,
|
|
41
|
+
permission
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (action === PermissionAction.CONFIRM) {
|
|
46
|
+
throw new AgentError(ErrorCode.CONFIRMATION_REQUIRED, `Confirmation required for ${toolName} (${permission}).`, {
|
|
47
|
+
toolName,
|
|
48
|
+
permission
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return permission;
|
|
53
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createDispatcher } from './dispatcher.js';
|
|
2
|
+
import { createServices } from '../services/index.js';
|
|
3
|
+
import { createEventBus } from '../events/bus.js';
|
|
4
|
+
import { createApprovalQueue } from '../permissions/approvals.js';
|
|
5
|
+
import { createPermissionController } from '../permissions/controller.js';
|
|
6
|
+
import { createPolicyManager } from '../permissions/manager.js';
|
|
7
|
+
import { normalizePolicy } from '../permissions/policy.js';
|
|
8
|
+
|
|
9
|
+
function legacyPermissionsToPolicy(permissions) {
|
|
10
|
+
if (!permissions) return undefined;
|
|
11
|
+
const map = (value) => value === 'confirm' ? 'ask' : value;
|
|
12
|
+
return {
|
|
13
|
+
categories: {
|
|
14
|
+
read: map(permissions.read),
|
|
15
|
+
write: map(permissions.write),
|
|
16
|
+
command: map(permissions.command)
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createRuntime({
|
|
22
|
+
root,
|
|
23
|
+
fullAccess = false,
|
|
24
|
+
permissions,
|
|
25
|
+
policy = legacyPermissionsToPolicy(permissions),
|
|
26
|
+
policyManager = createPolicyManager(normalizePolicy(policy)),
|
|
27
|
+
eventBus = createEventBus(),
|
|
28
|
+
approvalQueue,
|
|
29
|
+
interactive = !permissions
|
|
30
|
+
}) {
|
|
31
|
+
const activeApprovalQueue = interactive ? (approvalQueue ?? createApprovalQueue({ eventBus })) : null;
|
|
32
|
+
const services = createServices({ root, fullAccess });
|
|
33
|
+
const authorize = createPermissionController({ root, policyManager, approvalQueue: activeApprovalQueue, eventBus });
|
|
34
|
+
const dispatch = createDispatcher({ services, authorize, eventBus });
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
root,
|
|
38
|
+
fullAccess,
|
|
39
|
+
policyManager,
|
|
40
|
+
eventBus,
|
|
41
|
+
approvalQueue: activeApprovalQueue,
|
|
42
|
+
services,
|
|
43
|
+
dispatch
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function createEventBus({ maxHistory = 500 } = {}) {
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
const history = [];
|
|
6
|
+
|
|
7
|
+
function emit(type, data = {}) {
|
|
8
|
+
const event = {
|
|
9
|
+
id: randomUUID(),
|
|
10
|
+
type,
|
|
11
|
+
timestamp: new Date().toISOString(),
|
|
12
|
+
...data
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
history.push(event);
|
|
16
|
+
if (history.length > maxHistory) history.splice(0, history.length - maxHistory);
|
|
17
|
+
|
|
18
|
+
for (const listener of listeners) {
|
|
19
|
+
try {
|
|
20
|
+
listener(event);
|
|
21
|
+
} catch {
|
|
22
|
+
// Observers must never break agent execution.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return event;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function subscribe(listener) {
|
|
30
|
+
listeners.add(listener);
|
|
31
|
+
return () => listeners.delete(listener);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
emit,
|
|
36
|
+
subscribe,
|
|
37
|
+
getHistory: () => [...history]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
function auditSafeDetails(details) {
|
|
4
|
+
const input = { ...(details.input ?? {}) };
|
|
5
|
+
if (typeof input.content === 'string') input.content = `<${input.content.length} chars>`;
|
|
6
|
+
if (Array.isArray(input.lines)) input.lines = `<${input.lines.length} lines>`;
|
|
7
|
+
return { ...details, input };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createApprovalQueue({ eventBus } = {}) {
|
|
11
|
+
const pending = new Map();
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
|
|
14
|
+
function notify() {
|
|
15
|
+
const requests = [...pending.values()].map(({ resolve, ...request }) => request);
|
|
16
|
+
for (const listener of listeners) listener(requests);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function request(details) {
|
|
20
|
+
const id = randomUUID();
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
pending.set(id, { id, createdAt: new Date().toISOString(), ...details, resolve });
|
|
23
|
+
eventBus?.emit('permission.requested', { requestId: id, ...auditSafeDetails(details) });
|
|
24
|
+
notify();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveRequest(id, decision) {
|
|
29
|
+
const item = pending.get(id);
|
|
30
|
+
if (!item) return false;
|
|
31
|
+
pending.delete(id);
|
|
32
|
+
item.resolve(decision);
|
|
33
|
+
eventBus?.emit('permission.resolved', { requestId: id, ...decision });
|
|
34
|
+
notify();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
request,
|
|
40
|
+
resolve: resolveRequest,
|
|
41
|
+
getPending: () => [...pending.values()].map(({ resolve, ...request }) => request),
|
|
42
|
+
subscribe(listener) {
|
|
43
|
+
listeners.add(listener);
|
|
44
|
+
return () => listeners.delete(listener);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AgentError, ErrorCode } from '../core/errors.js';
|
|
2
|
+
import { classifyPath } from '../security/scope.js';
|
|
3
|
+
import { describeRequest, evaluateRequest, getRequestedPath, isFullAccessPolicy, matchCommandRule } from './evaluator.js';
|
|
4
|
+
import { Decision } from './policy.js';
|
|
5
|
+
|
|
6
|
+
function resourceOperation(toolName) {
|
|
7
|
+
if (toolName === 'list_directory') return 'list';
|
|
8
|
+
if (toolName === 'read_file') return 'read';
|
|
9
|
+
if (toolName === 'write_file') return 'create';
|
|
10
|
+
if (toolName === 'edit_file') return 'write';
|
|
11
|
+
if (toolName === 'run_command') return 'cwd';
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createPermissionController({ root, policyManager, approvalQueue, eventBus }) {
|
|
16
|
+
return async function authorize(toolName, input, requestId) {
|
|
17
|
+
const policy = policyManager.get();
|
|
18
|
+
let pathInfo = null;
|
|
19
|
+
let approvedByPrompt = false;
|
|
20
|
+
const requestedPath = getRequestedPath(toolName, input);
|
|
21
|
+
|
|
22
|
+
if (requestedPath) {
|
|
23
|
+
pathInfo = await classifyPath({
|
|
24
|
+
root,
|
|
25
|
+
additionalRoots: policy.additionalRoots,
|
|
26
|
+
userPath: requestedPath,
|
|
27
|
+
newFile: toolName === 'write_file'
|
|
28
|
+
});
|
|
29
|
+
eventBus?.emit('resource.access.requested', {
|
|
30
|
+
requestId,
|
|
31
|
+
tool: toolName,
|
|
32
|
+
resourceType: toolName === 'run_command' ? 'directory' : 'file',
|
|
33
|
+
operation: resourceOperation(toolName),
|
|
34
|
+
path: pathInfo.resolved,
|
|
35
|
+
scope: pathInfo.scope
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const evaluation = evaluateRequest({
|
|
40
|
+
toolName,
|
|
41
|
+
input,
|
|
42
|
+
policy,
|
|
43
|
+
pathScope: pathInfo?.scope === 'outside' ? 'outside' : 'root'
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
eventBus?.emit('permission.evaluated', {
|
|
47
|
+
requestId,
|
|
48
|
+
tool: toolName,
|
|
49
|
+
decision: evaluation.decision,
|
|
50
|
+
category: evaluation.category,
|
|
51
|
+
reason: evaluation.reason
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let approved = evaluation.decision === Decision.ALLOW;
|
|
55
|
+
if (evaluation.decision === Decision.ASK) {
|
|
56
|
+
if (!approvalQueue) {
|
|
57
|
+
throw new AgentError(ErrorCode.CONFIRMATION_REQUIRED, `Confirmation required: ${describeRequest(toolName, input)}`);
|
|
58
|
+
}
|
|
59
|
+
const decision = await approvalQueue.request({
|
|
60
|
+
toolName,
|
|
61
|
+
input,
|
|
62
|
+
description: describeRequest(toolName, input),
|
|
63
|
+
category: evaluation.category,
|
|
64
|
+
pathInfo,
|
|
65
|
+
evaluation
|
|
66
|
+
});
|
|
67
|
+
approved = decision.action === 'allow';
|
|
68
|
+
approvedByPrompt = approved;
|
|
69
|
+
if (approved && decision.remember === 'command' && toolName === 'run_command') {
|
|
70
|
+
await policyManager.addCommandRule({ executable: input.command, argsPrefix: input.args ?? [], action: 'allow' });
|
|
71
|
+
}
|
|
72
|
+
if (approved && decision.remember === 'root' && pathInfo?.scope === 'outside') {
|
|
73
|
+
await policyManager.addRoot(pathInfo.resolved);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!approved || evaluation.decision === Decision.DENY) {
|
|
78
|
+
throw new AgentError(ErrorCode.PERMISSION_DENIED, `Permission denied: ${describeRequest(toolName, input)}`, {
|
|
79
|
+
toolName,
|
|
80
|
+
category: evaluation.category
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const currentPolicy = policyManager.get();
|
|
85
|
+
return {
|
|
86
|
+
pathInfo,
|
|
87
|
+
allowCustomCommand: toolName === 'run_command' && (
|
|
88
|
+
isFullAccessPolicy(currentPolicy) || approvedByPrompt || Boolean(matchCommandRule(currentPolicy, input.command, input.args ?? []))
|
|
89
|
+
)
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Decision } from './policy.js';
|
|
2
|
+
|
|
3
|
+
const TOOL_CATEGORY = Object.freeze({
|
|
4
|
+
get_system_info: 'read',
|
|
5
|
+
list_directory: 'read',
|
|
6
|
+
read_file: 'read',
|
|
7
|
+
write_file: 'write',
|
|
8
|
+
edit_file: 'write',
|
|
9
|
+
run_command: 'command'
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const FILE_TOOLS = new Set(['list_directory', 'read_file', 'write_file', 'edit_file']);
|
|
13
|
+
const DANGEROUS_COMMANDS = new Set(['rm', 'rmdir', 'del', 'erase', 'format', 'shutdown', 'reboot', 'kill', 'taskkill']);
|
|
14
|
+
|
|
15
|
+
function startsWithArgs(args, prefix = []) {
|
|
16
|
+
return prefix.every((value, index) => args[index] === value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function matchCommandRule(policy, command, args = []) {
|
|
20
|
+
const matches = policy.commandRules
|
|
21
|
+
.map((rule, index) => ({ rule, index }))
|
|
22
|
+
.filter(({ rule }) => rule.executable === command && startsWithArgs(args, rule.argsPrefix ?? []));
|
|
23
|
+
|
|
24
|
+
if (!matches.length) return undefined;
|
|
25
|
+
|
|
26
|
+
// Prefer the most specific argument-prefix rule. If two rules are equally
|
|
27
|
+
// specific, prefer the latest one so a newer user choice wins predictably.
|
|
28
|
+
matches.sort((left, right) => {
|
|
29
|
+
const specificity = (right.rule.argsPrefix?.length ?? 0) - (left.rule.argsPrefix?.length ?? 0);
|
|
30
|
+
return specificity !== 0 ? specificity : right.index - left.index;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return matches[0].rule;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isDangerousCommand(command, args = []) {
|
|
37
|
+
if (DANGEROUS_COMMANDS.has(command)) return true;
|
|
38
|
+
if (command === 'git' && ['clean', 'reset'].includes(args[0])) return true;
|
|
39
|
+
if (['npm', 'pnpm', 'yarn'].includes(command) && ['uninstall', 'remove'].includes(args[0])) return true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getRequestedPath(toolName, input = {}) {
|
|
44
|
+
if (FILE_TOOLS.has(toolName) && typeof input.path === 'string') return input.path;
|
|
45
|
+
if (toolName === 'run_command' && typeof input.cwd === 'string') return input.cwd;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isFullAccessPolicy(policy) {
|
|
50
|
+
return ['read', 'write', 'command', 'dangerous', 'outsideRoot']
|
|
51
|
+
.every((category) => policy.categories[category] === Decision.ALLOW);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function evaluateRequest({ toolName, input = {}, policy, pathScope = 'root' }) {
|
|
55
|
+
const category = TOOL_CATEGORY[toolName];
|
|
56
|
+
if (!category) return { decision: Decision.DENY, category: 'unknown', reason: 'Unknown tool' };
|
|
57
|
+
|
|
58
|
+
if (pathScope === 'outside') {
|
|
59
|
+
return {
|
|
60
|
+
decision: policy.categories.outsideRoot ?? Decision.DENY,
|
|
61
|
+
category: 'outsideRoot',
|
|
62
|
+
reason: 'Path is outside configured roots'
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (toolName === 'run_command') {
|
|
67
|
+
const args = input.args ?? [];
|
|
68
|
+
const rule = matchCommandRule(policy, input.command, args);
|
|
69
|
+
if (rule) {
|
|
70
|
+
const pattern = [rule.executable, ...(rule.argsPrefix ?? [])].join(' ');
|
|
71
|
+
return { decision: rule.action, category: 'commandRule', rule, reason: `Matched command rule: ${pattern}` };
|
|
72
|
+
}
|
|
73
|
+
if (isDangerousCommand(input.command, args)) {
|
|
74
|
+
return {
|
|
75
|
+
decision: policy.categories.dangerous ?? Decision.ASK,
|
|
76
|
+
category: 'dangerous',
|
|
77
|
+
reason: 'Command is potentially destructive'
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
decision: policy.categories[category] ?? Decision.DENY,
|
|
84
|
+
category,
|
|
85
|
+
reason: `Policy category: ${category}`
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function describeRequest(toolName, input = {}) {
|
|
90
|
+
if (toolName === 'run_command') return `${input.command ?? ''} ${(input.args ?? []).join(' ')}`.trim();
|
|
91
|
+
if (typeof input.path === 'string') return `${toolName}: ${input.path}`;
|
|
92
|
+
return toolName;
|
|
93
|
+
}
|