@minionry/minion 0.7.36 → 0.7.39

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 (120) hide show
  1. package/bin/commands/minions.js +19 -0
  2. package/bin/minion.js +33 -4
  3. package/dist/server/cli/headless/claude-invoker-process.js +23 -7
  4. package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
  5. package/dist/server/cli/headless/haiku-assessments.js +2 -1
  6. package/dist/server/cli/headless/mcp-config.js +2 -1
  7. package/dist/server/engines/claude/claude-command.js +1 -0
  8. package/dist/server/engines/factory.js +2 -0
  9. package/dist/server/engines/hermes/HermesEngine.js +112 -219
  10. package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
  11. package/dist/server/index.js +10 -4
  12. package/dist/server/mcp/bouncer-integration.js +3 -2
  13. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
  14. package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
  15. package/dist/server/mcp/classifier/telemetry.js +0 -0
  16. package/dist/server/mcp/permission-channels.js +176 -0
  17. package/dist/server/mcp/security-analysis.js +9 -1
  18. package/dist/server/mcp/security-audit.js +22 -2
  19. package/dist/server/mcp/security-patterns.js +73 -1
  20. package/dist/server/mcp/server.js +80 -50
  21. package/dist/server/routes/internal.js +6 -1
  22. package/dist/server/routes/notifications.js +6 -18
  23. package/dist/server/server-setup.js +24 -48
  24. package/dist/server/services/analytics.js +4 -2
  25. package/dist/server/services/browser/host-mock.js +6 -1
  26. package/dist/server/services/browser/host.js +27 -3
  27. package/dist/server/services/browser/reasoning-agent.js +3 -1
  28. package/dist/server/services/chain/chain-engine.js +494 -0
  29. package/dist/server/services/chain/chain-store.js +542 -0
  30. package/dist/server/services/git/haiku.js +2 -2
  31. package/dist/server/services/plan/agents/check-injection.md +43 -2
  32. package/dist/server/services/plan/agents/review-code.md +62 -3
  33. package/dist/server/services/plan/agents/review-quality.md +52 -2
  34. package/dist/server/services/plan/board-export.js +4 -0
  35. package/dist/server/services/plan/composer-prompt.js +27 -8
  36. package/dist/server/services/plan/composer.js +10 -4
  37. package/dist/server/services/plan/config-installer.js +4 -83
  38. package/dist/server/services/plan/executor.js +155 -73
  39. package/dist/server/services/plan/issue-effort.js +9 -21
  40. package/dist/server/services/plan/issue-prompt-builder.js +72 -9
  41. package/dist/server/services/plan/parser-core.js +6 -1
  42. package/dist/server/services/plan/quality-delta.js +6 -24
  43. package/dist/server/services/plan/readiness-planner.js +12 -4
  44. package/dist/server/services/plan/record-file.js +52 -0
  45. package/dist/server/services/plan/review-approval.js +64 -0
  46. package/dist/server/services/plan/review-gate.js +186 -77
  47. package/dist/server/services/plan/review-outcome.js +146 -0
  48. package/dist/server/services/plan/review-report.js +28 -1
  49. package/dist/server/services/plan/review-target.js +57 -0
  50. package/dist/server/services/plan/state-reconciler.js +3 -3
  51. package/dist/server/services/plan/template-diff.js +2 -0
  52. package/dist/server/services/plan/template-instantiator.js +4 -0
  53. package/dist/server/services/plan/watcher.js +11 -1
  54. package/dist/server/services/platform-reconnect.js +7 -0
  55. package/dist/server/services/platform-token-lifecycle.js +2 -1
  56. package/dist/server/services/platform.js +293 -50
  57. package/dist/server/services/relay-http-client.js +107 -0
  58. package/dist/server/services/schedule/agent-schedule-store.js +69 -8
  59. package/dist/server/services/schedule/agent-scheduler.js +48 -7
  60. package/dist/server/services/schedule/daily-timing.js +52 -0
  61. package/dist/server/services/schedule/prompt-file.js +55 -0
  62. package/dist/server/services/schedule/run-bootstrap.js +20 -5
  63. package/dist/server/services/schedule/schedule-store.js +54 -4
  64. package/dist/server/services/schedule/schedule-wire.js +17 -0
  65. package/dist/server/services/schedule/scheduler.js +43 -9
  66. package/dist/server/services/sdk/agent-schedule.js +294 -0
  67. package/dist/server/services/sdk/agents.js +95 -13
  68. package/dist/server/services/sdk/app-tools.js +44 -7
  69. package/dist/server/services/sdk/atomic-write.js +75 -0
  70. package/dist/server/services/sdk/browser.js +5 -1
  71. package/dist/server/services/sdk/composer.js +2 -0
  72. package/dist/server/services/sdk/file-change-notify.js +60 -13
  73. package/dist/server/services/sdk/files-app.js +12 -7
  74. package/dist/server/services/sdk/files-search.js +14 -0
  75. package/dist/server/services/sdk/files-transfer.js +1 -1
  76. package/dist/server/services/sdk/files-watch.js +721 -0
  77. package/dist/server/services/sdk/files-workspace.js +37 -11
  78. package/dist/server/services/sdk/inference-claude.js +4 -2
  79. package/dist/server/services/sdk/inference.js +16 -2
  80. package/dist/server/services/sdk/pm-chain.js +0 -0
  81. package/dist/server/services/sdk/pm-schedule.js +72 -15
  82. package/dist/server/services/sdk/pm.js +22 -0
  83. package/dist/server/services/sdk/rate-limits.js +32 -0
  84. package/dist/server/services/sdk/registry.js +40 -2
  85. package/dist/server/services/sdk/terminal.js +1 -1
  86. package/dist/server/services/sentry.js +5 -1
  87. package/dist/server/services/terminal/pty-manager.js +26 -11
  88. package/dist/server/services/terminal/pty-utils.js +12 -1
  89. package/dist/server/services/terminal/terminal-modes.js +87 -0
  90. package/dist/server/services/timeline/assembler.js +2 -2
  91. package/dist/server/services/timeline/index.js +1 -1
  92. package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
  93. package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
  94. package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
  95. package/dist/server/services/websocket/browser-handlers.js +5 -2
  96. package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
  97. package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
  98. package/dist/server/services/websocket/file-transfer-http.js +12 -6
  99. package/dist/server/services/websocket/handler.js +185 -33
  100. package/dist/server/services/websocket/msg-id-tracker.js +94 -19
  101. package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
  102. package/dist/server/services/websocket/plan-handlers.js +10 -5
  103. package/dist/server/services/websocket/plan-helpers.js +23 -0
  104. package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
  105. package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
  106. package/dist/server/services/websocket/schedule-handlers.js +8 -2
  107. package/dist/server/services/websocket/sdk-agent-host.js +5 -1
  108. package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
  109. package/dist/server/services/websocket/sdk-browser-host.js +11 -4
  110. package/dist/server/services/websocket/sdk-handlers.js +6 -1
  111. package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
  112. package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
  113. package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
  114. package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
  115. package/dist/server/services/websocket/settings-handlers.js +6 -18
  116. package/dist/server/services/websocket/tab-broadcast.js +5 -1
  117. package/dist/server/services/websocket/terminal-handlers.js +51 -6
  118. package/dist/server/services/websocket/types.js +6 -5
  119. package/dist/server/services/websocket/viewer-interest.js +30 -0
  120. package/package.json +6 -6
@@ -0,0 +1,176 @@
1
+ // Copyright (c) 2025-present Minionry, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { createHash } from 'node:crypto';
4
+ export const BOUNCER_MCP_SERVER_NAME = 'minionry-bouncer';
5
+ export const PERMISSION_PROMPT_TOOL = 'approval_prompt';
6
+ export const PERMISSION_REQUEST_HOOK_TOOL = 'permission_request_hook';
7
+ export const PERMISSION_MODE_GUARD_TOOL = 'permission_mode_guard';
8
+ export const BOUNCER_PERMISSION_MODE = 'default';
9
+ export function bouncerToolName(tool) {
10
+ return `mcp__${BOUNCER_MCP_SERVER_NAME}__${tool}`;
11
+ }
12
+ export const BOUNCER_HOOK_TOOLS = [PERMISSION_REQUEST_HOOK_TOOL, PERMISSION_MODE_GUARD_TOOL].map(bouncerToolName);
13
+ const HOOK_INPUT_TEMPLATE = {
14
+ tool_name: '${tool_name}',
15
+ tool_input: '${tool_input}',
16
+ agent_id: '${agent_id}',
17
+ agent_type: '${agent_type}',
18
+ };
19
+ const BOUNCER_GOVERNED_MODES = new Set(['default', 'manual', 'plan', 'dontAsk']);
20
+ const MODE_GUARD_SKIPPED_TOOLS = [
21
+ 'Read',
22
+ 'Grep',
23
+ 'Glob',
24
+ 'ToolSearch',
25
+ 'TodoWrite',
26
+ 'TaskCreate',
27
+ 'TaskGet',
28
+ 'TaskList',
29
+ 'TaskUpdate',
30
+ ];
31
+ export const MODE_GUARD_MATCHER = `^(?!(${MODE_GUARD_SKIPPED_TOOLS.join('|')})$)`;
32
+ const MODE_GUARD_TIMEOUT_S = 30;
33
+ const MODE_GUARD_INPUT_TEMPLATE = {
34
+ tool_name: '${tool_name}',
35
+ agent_id: '${agent_id}',
36
+ agent_type: '${agent_type}',
37
+ permission_mode: '${permission_mode}',
38
+ };
39
+ export function bouncerSpawnSettings(permissionRequestTimeoutSeconds) {
40
+ return {
41
+ permissions: { disableBypassPermissionsMode: 'disable' },
42
+ hooks: {
43
+ PreToolUse: [
44
+ {
45
+ matcher: MODE_GUARD_MATCHER,
46
+ hooks: [
47
+ {
48
+ type: 'mcp_tool',
49
+ server: BOUNCER_MCP_SERVER_NAME,
50
+ tool: PERMISSION_MODE_GUARD_TOOL,
51
+ input: MODE_GUARD_INPUT_TEMPLATE,
52
+ timeout: MODE_GUARD_TIMEOUT_S,
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ PermissionRequest: [
58
+ {
59
+ matcher: '*',
60
+ hooks: [
61
+ {
62
+ type: 'mcp_tool',
63
+ server: BOUNCER_MCP_SERVER_NAME,
64
+ tool: PERMISSION_REQUEST_HOOK_TOOL,
65
+ input: HOOK_INPUT_TEMPLATE,
66
+ timeout: permissionRequestTimeoutSeconds,
67
+ },
68
+ ],
69
+ },
70
+ ],
71
+ },
72
+ };
73
+ }
74
+ function isRecord(value) {
75
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
76
+ }
77
+ function substitutedIdentifier(value) {
78
+ return typeof value === 'string' && value !== '' && !value.includes('${') ? value : undefined;
79
+ }
80
+ function toolInputObject(value) {
81
+ if (isRecord(value))
82
+ return value;
83
+ if (typeof value !== 'string')
84
+ return undefined;
85
+ try {
86
+ const parsed = JSON.parse(value);
87
+ return isRecord(parsed) ? parsed : undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ export function parsePermissionRequestHookArgs(args) {
94
+ if (!isRecord(args))
95
+ return { kind: 'malformed', reason: 'arguments are not an object' };
96
+ const subagentId = substitutedIdentifier(args.agent_id);
97
+ if (!subagentId)
98
+ return { kind: 'main-agent' };
99
+ const toolName = substitutedIdentifier(args.tool_name);
100
+ if (!toolName)
101
+ return { kind: 'malformed', reason: 'tool_name is missing' };
102
+ const toolInput = toolInputObject(args.tool_input);
103
+ if (!toolInput)
104
+ return { kind: 'malformed', reason: 'tool_input is not a JSON object' };
105
+ const subagentType = substitutedIdentifier(args.agent_type);
106
+ return {
107
+ kind: 'subagent',
108
+ toolName,
109
+ toolInput,
110
+ requester: { subagentId, ...(subagentType ? { subagentType } : {}) },
111
+ };
112
+ }
113
+ export function toPermissionPromptResponse(verdict, input) {
114
+ if (verdict.behavior === 'deny')
115
+ return { behavior: 'deny', message: verdict.message };
116
+ return { behavior: 'allow', updatedInput: verdict.updatedInput ?? input, message: verdict.caution };
117
+ }
118
+ export function toPermissionRequestHookOutput(verdict) {
119
+ const decision = verdict.behavior === 'deny'
120
+ ? { behavior: 'deny', message: verdict.message }
121
+ : { behavior: 'allow', ...(verdict.updatedInput ? { updatedInput: verdict.updatedInput } : {}) };
122
+ return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } };
123
+ }
124
+ export const NO_HOOK_DECISION = Object.freeze({});
125
+ export function decidePermissionModeGuard(args) {
126
+ if (!isRecord(args))
127
+ return { verdict: 'none' };
128
+ const permissionMode = substitutedIdentifier(args.permission_mode);
129
+ if (!permissionMode)
130
+ return { verdict: 'none' };
131
+ const toolName = substitutedIdentifier(args.tool_name);
132
+ if (toolName && MODE_GUARD_SKIPPED_TOOLS.includes(toolName))
133
+ return { verdict: 'none', permissionMode };
134
+ return { verdict: BOUNCER_GOVERNED_MODES.has(permissionMode) ? 'none' : 'ask', permissionMode };
135
+ }
136
+ export function toPermissionModeGuardOutput(decision) {
137
+ if (decision.verdict === 'none')
138
+ return NO_HOOK_DECISION;
139
+ return {
140
+ hookSpecificOutput: {
141
+ hookEventName: 'PreToolUse',
142
+ permissionDecision: 'ask',
143
+ permissionDecisionReason: `Minionry sends this call to its Security Bouncer: the ${decision.permissionMode} permission mode would otherwise approve it without review.`,
144
+ },
145
+ };
146
+ }
147
+ function canonicalJson(value) {
148
+ return JSON.stringify(value ?? null, (_key, nested) => isRecord(nested) ? Object.fromEntries(Object.keys(nested).sort().map((key) => [key, nested[key]])) : nested);
149
+ }
150
+ export function permissionDecisionKey(toolName, input) {
151
+ return createHash('sha256').update(toolName).update('\0').update(canonicalJson(input)).digest('hex');
152
+ }
153
+ export const PERMISSION_JOIN_WINDOW_MS = 25;
154
+ export class PermissionDecisionCoalescer {
155
+ joinWindow;
156
+ inFlight = new Map();
157
+ constructor(joinWindow = () => new Promise((resolve) => setTimeout(resolve, PERMISSION_JOIN_WINDOW_MS))) {
158
+ this.joinWindow = joinWindow;
159
+ }
160
+ decide(key, requester, review) {
161
+ const joined = this.inFlight.get(key);
162
+ if (joined) {
163
+ joined.requester ??= requester;
164
+ return joined.decision;
165
+ }
166
+ const entry = { requester };
167
+ entry.decision = this.joinWindow()
168
+ .then(() => review(entry.requester))
169
+ .finally(() => {
170
+ if (this.inFlight.get(key) === entry)
171
+ this.inFlight.delete(key);
172
+ });
173
+ this.inFlight.set(key, entry);
174
+ return entry.decision;
175
+ }
176
+ }
@@ -175,7 +175,7 @@ const READ_ONLY_DISQUALIFIERS = [
175
175
  /^(env|nice|timeout|nohup|stdbuf|ionice|setsid|chroot|doas)(\s|$)/i,
176
176
  ];
177
177
  const SENSITIVE_READ_TARGETS = /(\/etc\/(shadow|passwd|sudoers)|\.ssh\/|\.aws\/|\.gnupg\/|\.docker\/config|\.kube\/config|\.git-credentials|\.npmrc|\.netrc|\.pypirc|authorized_keys|id_rsa|id_ed25519|id_ecdsa|\.env(\.|\b)|credentials|\.pem\b|\.p12\b|\.keystore\b)/i;
178
- const CREDENTIAL_FILE_PATHS = /(\/etc\/(?:shadow|sudoers)\b|[~/]\.ssh\/|[~/]\.aws\/|[~/]\.gnupg\/|\.git-credentials\b|\.netrc\b|\.pypirc\b|authorized_keys\b|\bid_(?:rsa|ed25519|ecdsa|dsa)\b|\.pem\b|\.p12\b|\.pfx\b|\.keystore\b)/i;
178
+ const CREDENTIAL_FILE_PATHS = /(\/etc\/(?:shadow|sudoers)\b|[~/]\.ssh(?![\w-])|[~/]\.aws(?![\w-])|[~/]\.gnupg(?![\w-])|[~/]\.docker\/config|[~/]\.kube\/config|\.git-credentials\b|\.netrc\b|\.pypirc\b|authorized_keys\b|\bid_(?:rsa|ed25519|ecdsa|dsa)\b|\.pem\b|\.p12\b|\.pfx\b|\.keystore\b|\.config\/(?:google-chrome|chromium|BraveSoftware|microsoft-edge)(?![\w-])|Application Support\/(?:Google\/Chrome|Chromium|BraveSoftware|Microsoft Edge|Firefox)(?![\w-])|[~/]\.mozilla\/firefox(?![\w-])|\bLogin Data\b|\bcookies\.sqlite\b)/i;
179
179
  function segmentIsReadOnly(rawSegment) {
180
180
  const segment = unwrapCommandPrefix(rawSegment.trim()).replace(/^\[\[?\s/, 'test ');
181
181
  if (READ_ONLY_DISQUALIFIERS.some((p) => p.test(segment)))
@@ -304,6 +304,8 @@ export function requiresAIReview(operation) {
304
304
  const op = normalizeOperation(operation);
305
305
  if (matchesPattern(op, SENSITIVE_PATHS))
306
306
  return true;
307
+ if (namesCredentialStore(op))
308
+ return true;
307
309
  if (/^Bash:/i.test(op) && containsAnyExpansion(op) && !bashExpansionIsReadOnlySafe(op)) {
308
310
  return true;
309
311
  }
@@ -330,6 +332,12 @@ export function requiresAIReview(operation) {
330
332
  export function isSensitivePath(operation) {
331
333
  return matchesPattern(operation, SENSITIVE_PATHS);
332
334
  }
335
+ export function namesCredentialStore(operation) {
336
+ const op = normalizeOperation(operation);
337
+ if (!/^Bash:/i.test(op))
338
+ return false;
339
+ return CREDENTIAL_FILE_PATHS.test(op);
340
+ }
333
341
  export function classifyRisk(operation) {
334
342
  const criticalThreat = matchesPattern(operation, CRITICAL_THREATS);
335
343
  if (criticalThreat) {
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { createHash } from 'node:crypto';
4
- import { appendFileSync, existsSync, mkdirSync, readFileSync, } from 'node:fs';
4
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { SPACE_DIRNAME, SPACE_LAYOUT } from '../services/space-layout.js';
7
7
  const DEFAULT_LOG_SUBDIR = join(SPACE_DIRNAME, SPACE_LAYOUT.logs);
@@ -46,7 +46,7 @@ export class SecurityAuditLogger {
46
46
  return [];
47
47
  let content;
48
48
  try {
49
- content = readFileSync(this.logFile, 'utf-8');
49
+ content = this.readTail(opts?.limit);
50
50
  }
51
51
  catch (error) {
52
52
  console.error('[SecurityAudit] Failed to read log:', error);
@@ -64,6 +64,26 @@ export class SecurityAuditLogger {
64
64
  }
65
65
  return entries;
66
66
  }
67
+ readTail(limit) {
68
+ if (!limit || limit <= 0)
69
+ return readFileSync(this.logFile, 'utf-8');
70
+ const BYTES_PER_ENTRY_ESTIMATE = 2048;
71
+ const want = limit * BYTES_PER_ENTRY_ESTIMATE;
72
+ const { size } = statSync(this.logFile);
73
+ if (size <= want)
74
+ return readFileSync(this.logFile, 'utf-8');
75
+ const fd = openSync(this.logFile, 'r');
76
+ try {
77
+ const buf = Buffer.allocUnsafe(want);
78
+ readSync(fd, buf, 0, want, size - want);
79
+ const text = buf.toString('utf-8');
80
+ const firstBreak = text.indexOf('\n');
81
+ return firstBreak === -1 ? '' : text.slice(firstBreak + 1);
82
+ }
83
+ finally {
84
+ closeSync(fd);
85
+ }
86
+ }
67
87
  logDecision(operation, decision, confidence, reasoning, metadata) {
68
88
  this.log({
69
89
  timestamp: new Date().toISOString(),
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { resolve } from 'node:path';
4
- export { classifyRisk, isDeployMode, isSensitivePath, requiresAIReview } from './security-analysis.js';
4
+ export { classifyRisk, isDeployMode, isSensitivePath, namesCredentialStore, requiresAIReview, } from './security-analysis.js';
5
5
  export const SENSITIVE_PATHS = [
6
6
  { pattern: /^(Write|Edit):\s*\/etc\//i, reason: 'System configuration - verify user intent' },
7
7
  { pattern: /^(Write|Edit):\s*\/(bin|sbin|usr\/bin|usr\/sbin|usr\/local\/bin|usr\/local\/sbin)\//i, reason: 'System binaries - verify user intent' },
@@ -181,6 +181,78 @@ export const NEEDS_AI_REVIEW = [
181
181
  reason: 'Inline script code with dynamic execution or system access - verify user intent'
182
182
  },
183
183
  { pattern: /\bclaude\b.*\bmcp\b.*\badd\b/i, reason: 'Adding MCP server - verify source is trusted' },
184
+ {
185
+ pattern: /(?:^|[\s;|&`("'])history\s+-c\b/i,
186
+ reason: 'Clearing shell history - potential cover-up of a prior operation'
187
+ },
188
+ {
189
+ pattern: /(?:^|[\s;|&`("'])(?:rm|shred|truncate|unlink)\b[^\n;|&]*[~/.](?:bash_history|zsh_history|sh_history|python_history)\b/i,
190
+ reason: 'Destroying shell history - potential cover-up of a prior operation'
191
+ },
192
+ {
193
+ pattern: /(?:(?:^|[\s;|&`("'])(?:truncate|shred|rm)\b[^\n;|&]*|>\s*)\/var\/log\//i,
194
+ reason: 'Truncating or deleting a system log - potential audit-trail tampering'
195
+ },
196
+ {
197
+ pattern: /\bsystemctl\s+(?:stop|disable|mask)\s+\S*(?:auditd|rsyslog|syslog|journald|falco|osquery|wazuh)\S*/i,
198
+ reason: 'Stopping an audit/monitoring service - potential oversight disabling'
199
+ },
200
+ {
201
+ pattern: /\bservice\s+\S*(?:auditd|rsyslog|syslog|falco|osquery|wazuh)\S*\s+(?:stop|disable)\b/i,
202
+ reason: 'Stopping an audit/monitoring service - potential oversight disabling'
203
+ },
204
+ {
205
+ pattern: /(?:^|[\s;|&`("'])(?:auditctl\s+-[eD]\b|launchctl\s+(?:unload|bootout|disable)\b)/i,
206
+ reason: 'Disabling audit or launch services - potential oversight disabling'
207
+ },
208
+ {
209
+ pattern: /(?:^|[\s;|&`("'/])(?:ngrok|cloudflared|localtunnel|bore|frpc|pagekite)\b/i,
210
+ reason: 'Dev tunnel - exposes a local service to the public internet; verify user intent'
211
+ },
212
+ {
213
+ pattern: /(?:^|[\s;|&`("'/])tailscale\s+funnel\b/i,
214
+ reason: 'Tailscale funnel - exposes a local service publicly; verify user intent'
215
+ },
216
+ {
217
+ pattern: /\bssh\b[^\n;|&]*\s-R\s*\S/i,
218
+ reason: 'SSH remote port forward - exposes a local port to a remote host'
219
+ },
220
+ {
221
+ pattern: /(?:^|[\s;|&`("'/])socat\b[^\n]*(?:EXEC|SYSTEM):/i,
222
+ reason: 'socat with EXEC/SYSTEM - bind or reverse shell'
223
+ },
224
+ {
225
+ pattern: /(?:^|[\s;|&`("'/])socat\b[^\n]*TCP\d?-LISTEN/i,
226
+ reason: 'socat listener - potential bind shell'
227
+ },
228
+ {
229
+ pattern: /\b(?:npm|yarn|pnpm|bun)\s+config\s+set\s+(?:@\S+:)?registry\b/i,
230
+ reason: 'Persisting a non-default npm registry - potential supply chain redirection'
231
+ },
232
+ {
233
+ pattern: /\bpip3?\s+config\s+set\b[^\n]*index-url\b/i,
234
+ reason: 'Persisting a non-default pip index - potential supply chain redirection'
235
+ },
236
+ {
237
+ pattern: /\bgit\s+push\b[^\n;|&]*\s(?:--force(?!-with-lease)|-f)(?=\s|$)/i,
238
+ reason: 'Force-push overwrites remote history - verify the branch and user intent'
239
+ },
240
+ {
241
+ pattern: /\bkubectl\s+delete\s+(?:ns|namespace|deployment|statefulset|pvc|persistentvolumeclaim|secret)\b/i,
242
+ reason: 'Kubernetes resource teardown - verify the target matches user intent'
243
+ },
244
+ {
245
+ pattern: /\b(?:terraform|tofu)\s+destroy\b/i,
246
+ reason: 'Terraform destroy - tears down managed infrastructure; verify user intent'
247
+ },
248
+ {
249
+ pattern: /\baws\s+s3\s+(?:rb|rm)\b[^\n]*(?:--force|--recursive)\b/i,
250
+ reason: 'Recursive S3 bucket deletion - verify the target matches user intent'
251
+ },
252
+ {
253
+ pattern: /\baws\s+(?:ec2\s+terminate-instances|rds\s+delete-db-(?:instance|cluster))\b/i,
254
+ reason: 'Cloud instance/database teardown - verify the target matches user intent'
255
+ },
184
256
  ...GIT_SDK_REMOTE_REVIEW_TOOLS,
185
257
  ];
186
258
  export const BROWSER_SAFE_TOOLS = [
@@ -4,8 +4,9 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
5
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
6
6
  import { Agent } from 'undici';
7
- import { reviewEnginePermission, sdkRunReviewContextFromEnv, setRemoteDecisionCache, } from './bouncer-integration.js';
7
+ import { formatDenialMessage, reviewEnginePermission, sdkRunReviewContextFromEnv, setRemoteDecisionCache, } from './bouncer-integration.js';
8
8
  import { sweepClassifierTranscripts } from './classifier/ClaudeBouncerClassifier.js';
9
+ import { BOUNCER_MCP_SERVER_NAME, decidePermissionModeGuard, NO_HOOK_DECISION, PERMISSION_MODE_GUARD_TOOL, PERMISSION_PROMPT_TOOL, PERMISSION_REQUEST_HOOK_TOOL, PermissionDecisionCoalescer, parsePermissionRequestHookArgs, permissionDecisionKey, toPermissionModeGuardOutput, toPermissionPromptResponse, toPermissionRequestHookOutput, } from './permission-channels.js';
9
10
  const REMOTE_CACHE_TIMEOUT_MS = 400;
10
11
  function installRemoteDecisionCache() {
11
12
  const port = process.env.MINIONRY_PORT;
@@ -42,7 +43,7 @@ function installRemoteDecisionCache() {
42
43
  }
43
44
  const askUserQuestionDispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0 });
44
45
  const server = new Server({
45
- name: 'minionry-bouncer',
46
+ name: BOUNCER_MCP_SERVER_NAME,
46
47
  version: '1.0.0',
47
48
  }, {
48
49
  capabilities: {
@@ -53,7 +54,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
53
54
  return {
54
55
  tools: [
55
56
  {
56
- name: 'approval_prompt',
57
+ name: PERMISSION_PROMPT_TOOL,
57
58
  description: 'Analyze and approve/deny tool use requests from Claude Code. Integrates with Minionry security bouncer for AI-powered risk analysis.',
58
59
  inputSchema: {
59
60
  type: 'object',
@@ -70,6 +71,32 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
70
71
  required: ['tool_name', 'input'],
71
72
  },
72
73
  },
74
+ {
75
+ name: PERMISSION_REQUEST_HOOK_TOOL,
76
+ description: 'Internal: answers Claude Code PermissionRequest hooks for subagent tool calls. Called by the hook, never by the model.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: {
80
+ tool_name: { type: 'string', description: 'Name of the tool being requested' },
81
+ tool_input: { description: 'Tool input parameters, JSON-encoded by the hook' },
82
+ agent_id: { type: 'string', description: 'The requesting subagent; empty for the main agent' },
83
+ agent_type: { type: 'string', description: 'The requesting subagent type' },
84
+ },
85
+ },
86
+ },
87
+ {
88
+ name: PERMISSION_MODE_GUARD_TOOL,
89
+ description: 'Internal: answers Claude Code PreToolUse hooks so no permission mode approves a call without the Bouncer. Called by the hook, never by the model.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ tool_name: { type: 'string', description: 'Name of the tool about to run' },
94
+ agent_id: { type: 'string', description: 'The calling subagent; empty for the main agent' },
95
+ agent_type: { type: 'string', description: 'The calling subagent type' },
96
+ permission_mode: { type: 'string', description: 'The permission mode the call runs in' },
97
+ },
98
+ },
99
+ },
73
100
  ],
74
101
  };
75
102
  });
@@ -81,7 +108,7 @@ async function bridgeAskUserQuestion(input) {
81
108
  const toolUseId = process.env.MINIONRY_CURRENT_TOOL_USE_ID || `aq-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
82
109
  if (!port || !tabId || !secret || !capabilityToken) {
83
110
  console.error('[MCP Bouncer] AskUserQuestion: missing routing context (port/tabId/secret/capability token) — passing through with no answers');
84
- return { behavior: 'allow', updatedInput: input };
111
+ return { behavior: 'allow' };
85
112
  }
86
113
  try {
87
114
  const requestOptions = {
@@ -97,7 +124,7 @@ async function bridgeAskUserQuestion(input) {
97
124
  const res = await fetch(`http://127.0.0.1:${port}/internal/ask-user-question`, requestOptions);
98
125
  if (!res.ok) {
99
126
  console.error(`[MCP Bouncer] AskUserQuestion bridge returned ${res.status} — passing through with no answers`);
100
- return { behavior: 'allow', updatedInput: input };
127
+ return { behavior: 'allow' };
101
128
  }
102
129
  const json = (await res.json());
103
130
  const answers = json.answers && typeof json.answers === 'object' ? json.answers : {};
@@ -108,76 +135,79 @@ async function bridgeAskUserQuestion(input) {
108
135
  }
109
136
  catch (err) {
110
137
  console.error(`[MCP Bouncer] AskUserQuestion bridge failed: ${err instanceof Error ? err.message : String(err)} — passing through with no answers`);
111
- return { behavior: 'allow', updatedInput: input };
138
+ return { behavior: 'allow' };
112
139
  }
113
140
  }
114
- function toMcpResponse(decision, input) {
141
+ function verdictFromDecision(decision) {
115
142
  if (decision.decision === 'deny') {
116
- return {
117
- behavior: 'deny',
118
- message: `🚫 ${decision.reasoning}${decision.alternative ? `\n\nAlternative: ${decision.alternative}` : ''}`,
119
- };
143
+ return { behavior: 'deny', message: formatDenialMessage(decision) };
120
144
  }
121
- return {
122
- behavior: 'allow',
123
- updatedInput: input,
124
- message: decision.decision === 'warn_allow'
125
- ? `⚠️ Allowed with caution: ${decision.reasoning}`
126
- : undefined,
127
- };
145
+ return decision.decision === 'warn_allow'
146
+ ? { behavior: 'allow', caution: `⚠️ Allowed with caution: ${decision.reasoning}` }
147
+ : { behavior: 'allow' };
128
148
  }
129
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
130
- if (request.params.name !== 'approval_prompt') {
131
- throw new Error(`Unknown tool: ${request.params.name}`);
132
- }
133
- const { tool_name, input } = request.params.arguments;
134
- if (tool_name === 'AskUserQuestion') {
149
+ async function reviewPermission(toolName, input, requester) {
150
+ if (toolName === 'AskUserQuestion') {
135
151
  console.error('[MCP Bouncer] AskUserQuestion received — bridging to web UI');
136
- const response = await bridgeAskUserQuestion(input);
137
- return {
138
- content: [{ type: 'text', text: JSON.stringify(response) }],
139
- };
152
+ return bridgeAskUserQuestion(input);
140
153
  }
141
- console.error(`[MCP Bouncer] Analyzing ${tool_name} request...`);
154
+ const from = requester ? ` from subagent ${requester.subagentType ?? '(unknown type)'} ${requester.subagentId}` : '';
155
+ console.error(`[MCP Bouncer] Analyzing ${toolName} request${from}...`);
142
156
  const sdkRunContext = sdkRunReviewContextFromEnv(process.env);
143
157
  try {
144
158
  const decision = await reviewEnginePermission({
145
- toolName: tool_name,
159
+ toolName,
146
160
  input,
147
161
  context: {
148
162
  purpose: `Tool use request from Claude`,
149
163
  workingDirectory: process.cwd(),
150
164
  userRequest: process.env.BOUNCER_USER_PROMPT,
151
165
  ...(sdkRunContext ?? {}),
166
+ ...(requester ?? {}),
152
167
  },
153
168
  });
154
169
  console.error(`[MCP Bouncer] Decision: ${decision.decision} (${decision.confidence}% confidence)`);
155
170
  console.error(`[MCP Bouncer] Reasoning: ${decision.reasoning}`);
156
- const response = toMcpResponse(decision, input);
157
- return {
158
- content: [
159
- {
160
- type: 'text',
161
- text: JSON.stringify(response),
162
- },
163
- ],
164
- };
171
+ return verdictFromDecision(decision);
165
172
  }
166
173
  catch (error) {
167
174
  const errorMessage = error instanceof Error ? error.message : String(error);
168
175
  console.error(`[MCP Bouncer] Error: ${errorMessage}`);
169
- return {
170
- content: [
171
- {
172
- type: 'text',
173
- text: JSON.stringify({
174
- behavior: 'deny',
175
- message: `Security analysis failed: ${errorMessage}. Denying for safety.`,
176
- }),
177
- },
178
- ],
179
- };
176
+ return { behavior: 'deny', message: `Security analysis failed: ${errorMessage}. Denying for safety.` };
177
+ }
178
+ }
179
+ const permissionDecisions = new PermissionDecisionCoalescer();
180
+ function decidePermission(toolName, input, requester) {
181
+ return permissionDecisions.decide(permissionDecisionKey(toolName, input), requester, (who) => reviewPermission(toolName, input, who));
182
+ }
183
+ function textResult(value) {
184
+ return { content: [{ type: 'text', text: JSON.stringify(value) }] };
185
+ }
186
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
187
+ if (request.params.name === PERMISSION_PROMPT_TOOL) {
188
+ const { tool_name, input } = request.params.arguments;
189
+ const verdict = await decidePermission(tool_name, input);
190
+ return textResult(toPermissionPromptResponse(verdict, input));
191
+ }
192
+ if (request.params.name === PERMISSION_REQUEST_HOOK_TOOL) {
193
+ const call = parsePermissionRequestHookArgs(request.params.arguments);
194
+ if (call.kind === 'malformed') {
195
+ console.error(`[MCP Bouncer] PermissionRequest hook: ${call.reason} — no decision`);
196
+ }
197
+ if (call.kind !== 'subagent')
198
+ return textResult(NO_HOOK_DECISION);
199
+ const verdict = await decidePermission(call.toolName, call.toolInput, call.requester);
200
+ return textResult(toPermissionRequestHookOutput(verdict));
201
+ }
202
+ if (request.params.name === PERMISSION_MODE_GUARD_TOOL) {
203
+ const decision = decidePermissionModeGuard(request.params.arguments);
204
+ if (decision.verdict === 'ask') {
205
+ const args = request.params.arguments;
206
+ console.error(`[MCP Bouncer] Mode guard: ${String(args?.tool_name)} runs in ${decision.permissionMode} (${String(args?.agent_type || 'main agent')}) — asking the Bouncer`);
207
+ }
208
+ return textResult(toPermissionModeGuardOutput(decision));
180
209
  }
210
+ throw new Error(`Unknown tool: ${request.params.name}`);
181
211
  });
182
212
  async function main() {
183
213
  installRemoteDecisionCache();
@@ -254,7 +254,8 @@ const BROWSER_OP_HANDLERS = {
254
254
  },
255
255
  'tab.open': (host, p) => {
256
256
  const url = requireString(p, 'url');
257
- return host.openTab(url);
257
+ const label = optionalString(p, 'agentLabel');
258
+ return host.openTab(url, { kind: 'agent', ...(label !== undefined ? { label } : {}) });
258
259
  },
259
260
  'tab.close': async (host, p) => {
260
261
  const id = requireString(p, 'id');
@@ -547,6 +548,10 @@ function bindBrowserOp(body, identity) {
547
548
  params.agentId = identity.agentId;
548
549
  if (identity.planId !== undefined)
549
550
  params.planId = identity.planId;
551
+ if (identity.agentLabel !== undefined)
552
+ params.agentLabel = identity.agentLabel;
553
+ else
554
+ delete params.agentLabel;
550
555
  if (typeof params.tabId !== 'string' || !params.tabId) {
551
556
  if (identity.defaultTabId !== undefined)
552
557
  params.tabId = identity.defaultTabId;
@@ -1,11 +1,8 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
3
  import { spawn } from 'node:child_process';
4
- import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
5
- import { join } from 'node:path';
6
4
  import { Hono } from 'hono';
7
- import { resolveClaudeCommand } from '../engines/claude/claude-command.js';
8
- import { spacePath } from '../services/space-layout.js';
5
+ import { resolveClaudeCommand, TOOL_LESS_CLAUDE_ARGS } from '../engines/claude/claude-command.js';
9
6
  export function createNotificationRoutes(workingDir) {
10
7
  const routes = new Hono();
11
8
  routes.post('/summarize', async (c) => {
@@ -27,10 +24,6 @@ export function createNotificationRoutes(workingDir) {
27
24
  }
28
25
  async function generateNotificationSummary(userPrompt, output, workingDir) {
29
26
  return new Promise((resolve) => {
30
- const tempDir = spacePath(workingDir, 'tmp');
31
- if (!existsSync(tempDir)) {
32
- mkdirSync(tempDir, { recursive: true });
33
- }
34
27
  let truncatedOutput = output;
35
28
  if (output.length > 4000) {
36
29
  const firstPart = output.slice(0, 2000);
@@ -55,19 +48,19 @@ Generate a notification summary following these rules:
55
48
  6. No emojis, no markdown, just plain text
56
49
 
57
50
  Respond with ONLY the summary text, nothing else.`;
58
- const promptFile = join(tempDir, `notif-summary-${Date.now()}.txt`);
59
- writeFileSync(promptFile, summaryPrompt);
60
51
  const systemPrompt = 'You are a notification summary assistant. Respond with only the summary text, no preamble or explanation.';
61
52
  const args = [
62
53
  '--print',
63
54
  '--model', 'haiku',
64
- '--system-prompt', systemPrompt,
65
- promptFile
55
+ ...TOOL_LESS_CLAUDE_ARGS,
56
+ '--system-prompt', systemPrompt
66
57
  ];
67
58
  const claude = spawn(resolveClaudeCommand(), args, {
68
59
  cwd: workingDir,
69
- stdio: ['ignore', 'pipe', 'pipe']
60
+ stdio: ['pipe', 'pipe', 'pipe']
70
61
  });
62
+ claude.stdin?.on('error', () => { });
63
+ claude.stdin?.end(summaryPrompt);
71
64
  let stdout = '';
72
65
  let stderr = '';
73
66
  claude.stdout?.on('data', (data) => {
@@ -77,11 +70,6 @@ Respond with ONLY the summary text, nothing else.`;
77
70
  stderr += data.toString();
78
71
  });
79
72
  claude.on('close', (code) => {
80
- try {
81
- unlinkSync(promptFile);
82
- }
83
- catch {
84
- }
85
73
  if (code === 0 && stdout.trim()) {
86
74
  const summary = stdout.trim().slice(0, 150);
87
75
  resolve(summary);