@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.
Files changed (50) hide show
  1. package/README.md +337 -0
  2. package/package.json +32 -0
  3. package/src/audit/logger.js +30 -0
  4. package/src/cli/commands/cloud.js +89 -0
  5. package/src/cli/commands/doctor.js +18 -0
  6. package/src/cli/commands/login.js +59 -0
  7. package/src/cli/commands/logout.js +20 -0
  8. package/src/cli/commands/remote.js +64 -0
  9. package/src/cli/commands/status.js +32 -0
  10. package/src/cli/commands/update.js +31 -0
  11. package/src/cli/help.js +52 -0
  12. package/src/cli/main.js +55 -0
  13. package/src/cli/options.js +34 -0
  14. package/src/cli.js +20 -0
  15. package/src/core/dispatcher.js +49 -0
  16. package/src/core/errors.js +37 -0
  17. package/src/core/permissions.js +53 -0
  18. package/src/core/runtime.js +45 -0
  19. package/src/events/bus.js +39 -0
  20. package/src/permissions/approvals.js +47 -0
  21. package/src/permissions/controller.js +92 -0
  22. package/src/permissions/evaluator.js +93 -0
  23. package/src/permissions/manager.js +57 -0
  24. package/src/permissions/policy.js +28 -0
  25. package/src/permissions/store.js +27 -0
  26. package/src/security/path.js +61 -0
  27. package/src/security/scope.js +45 -0
  28. package/src/server.js +1 -0
  29. package/src/services/commands.js +107 -0
  30. package/src/services/files.js +104 -0
  31. package/src/services/index.js +11 -0
  32. package/src/services/system.js +17 -0
  33. package/src/transport/cloud.js +206 -0
  34. package/src/transport/mcp/manifest-store.js +32 -0
  35. package/src/transport/mcp/response.js +25 -0
  36. package/src/transport/mcp/server.js +114 -0
  37. package/src/transport/mcp/tools/commands.js +33 -0
  38. package/src/transport/mcp/tools/files.js +55 -0
  39. package/src/transport/mcp/tools/index.js +1 -0
  40. package/src/transport/mcp/tools/registry.js +92 -0
  41. package/src/transport/mcp/tools/system.js +16 -0
  42. package/src/tui/app.js +392 -0
  43. package/src/tui/commands.js +71 -0
  44. package/src/tui/index.js +37 -0
  45. package/src/tui/layout.js +48 -0
  46. package/src/tui/model.js +62 -0
  47. package/src/tui/profiles.js +32 -0
  48. package/src/utils/credentials.js +35 -0
  49. package/src/utils/text.js +180 -0
  50. 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
+ }
@@ -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
+ }
@@ -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
+ }