@miphamai/cli 0.24.1 → 0.24.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -8,6 +8,7 @@ import type { HookEngine } from '../core/hooks'
8
8
  import type { PermissionSystem } from '../core/permission'
9
9
  import { AgentExperience } from './agent-experience'
10
10
  import { PatternAnalyzer } from './pattern-analyzer.js'
11
+ import { getWorkspaceTrust } from '../core/workspace-trust'
11
12
  import type { ExperienceRuleEngine } from '../core/rule-engine.js'
12
13
 
13
14
  // Singleton instances (created lazily)
@@ -200,6 +201,19 @@ export class SubAgent {
200
201
  // Resolve execution directory: worktree isolation or process cwd
201
202
  const execCwd = options.worktreePath || process.cwd()
202
203
 
204
+ // Check workspace trust for the execution directory.
205
+ // For worktree-isolated agents, auto-trust the worktree if the parent is trusted.
206
+ if (options.worktreePath) {
207
+ const trust = getWorkspaceTrust()
208
+ if (!trust.isTrusted(execCwd)) {
209
+ // Auto-trust worktree directories when the parent workspace is trusted
210
+ if (trust.isTrusted(process.cwd())) {
211
+ trust.trust(execCwd)
212
+ }
213
+ // Otherwise, proceed with a warning — don't block agent execution
214
+ }
215
+ }
216
+
203
217
  // Resolve system prompt: agentDef > options.systemPrompt > builtin type
204
218
  const systemPrompt =
205
219
  agentDef?.systemPrompt || options.systemPrompt || TYPE_SYSTEM_PROMPTS[agentType]
@@ -6,8 +6,18 @@
6
6
  * /security, /audit, /prompt-audit
7
7
  */
8
8
  import type { CommandHandler, CommandContext, CommandResult } from '../ui/commands.js'
9
-
10
- export { initCmd, permissionsCmd, recommendCmd, setupCmd, addDirCmd, promptAuditCmd, securityCmd }
9
+ import { getWorkspaceTrust } from '../core/workspace-trust'
10
+
11
+ export {
12
+ initCmd,
13
+ permissionsCmd,
14
+ recommendCmd,
15
+ setupCmd,
16
+ addDirCmd,
17
+ promptAuditCmd,
18
+ securityCmd,
19
+ trustCmd,
20
+ }
11
21
 
12
22
  const initCmd: CommandHandler = async (ctx) => {
13
23
  const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
@@ -1004,3 +1014,35 @@ const securityCmd: CommandHandler = async () => {
1004
1014
 
1005
1015
  return { content: lines.join('\n') }
1006
1016
  }
1017
+
1018
+ const trustCmd: CommandHandler = (ctx) => {
1019
+ const trust = getWorkspaceTrust()
1020
+ const cwd = process.cwd()
1021
+ const trusted = trust.listTrusted()
1022
+
1023
+ const lines: string[] = [
1024
+ '── Workspace Trust ──',
1025
+ '',
1026
+ `Current directory: ${cwd}`,
1027
+ `Trusted: ${trust.isTrusted(cwd) ? '✅ Yes' : '⚠️ No (restart to trust)'}`,
1028
+ `Trust store: ${trust.getStorePath()}`,
1029
+ '',
1030
+ ]
1031
+
1032
+ if (trusted.length === 0) {
1033
+ lines.push('No trusted workspaces configured.')
1034
+ lines.push('')
1035
+ lines.push('Trust a workspace: restart Mipham Code in the directory')
1036
+ lines.push('and accept the trust prompt.')
1037
+ } else {
1038
+ lines.push(`Trusted workspaces (${trusted.length}):`)
1039
+ for (const dir of trusted) {
1040
+ const marker = cwd.startsWith(dir) ? ' ← current' : ''
1041
+ lines.push(` • ${dir}${marker}`)
1042
+ }
1043
+ lines.push('')
1044
+ lines.push('Remove a workspace: delete the directory entry from the trust store.')
1045
+ }
1046
+
1047
+ return { content: lines.join('\n') }
1048
+ }
@@ -4,6 +4,7 @@ import type {
4
4
  ToolResult,
5
5
  InferenceHookConfig,
6
6
  CrsiConfig,
7
+ CrossSessionConfig,
7
8
  } from '../shared/index.ts'
8
9
  import { ProviderRegistry } from '../providers/registry'
9
10
  import { ContextManager } from './context'
@@ -69,6 +70,12 @@ export class QueryEngine {
69
70
  /** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
70
71
  private sessionId: string = 'session-1'
71
72
 
73
+ /** Cross-session inbound policy. Default: 'ask' (messages require user review). */
74
+ private crossSessionConfig: CrossSessionConfig = {
75
+ crossSessionInbound: 'ask',
76
+ dialogExpiry: 300,
77
+ }
78
+
72
79
  /** Set the session identifier (called from index.tsx at startup with the actual session name). */
73
80
  setSessionId(id: string): void {
74
81
  this.sessionId = id
@@ -79,15 +86,45 @@ export class QueryEngine {
79
86
  return this.sessionId
80
87
  }
81
88
 
89
+ /** Set the cross-session messaging policy (called from index.tsx at startup). */
90
+ setCrossSessionConfig(config: CrossSessionConfig): void {
91
+ this.crossSessionConfig = config
92
+ }
93
+
82
94
  /** Poll the cross-session file inbox for messages addressed to this session. */
83
95
  async pollCrossSessionInbox(): Promise<void> {
96
+ const policy = this.crossSessionConfig.crossSessionInbound
97
+
98
+ // 'deny' — silently discard all inbound messages
99
+ if (policy === 'deny') {
100
+ try {
101
+ const transport = getFileInboxTransport()
102
+ await transport.poll(this.sessionId) // read + delete, don't forward
103
+ } catch {
104
+ // Best-effort discard
105
+ }
106
+ return
107
+ }
108
+
84
109
  try {
85
110
  const transport = getFileInboxTransport()
86
111
  const messages = await transport.poll(this.sessionId)
87
112
  if (messages.length > 0) {
88
113
  const bus = getMessageBus()
89
114
  for (const msg of messages) {
90
- bus.post(msg.from, msg.to, msg.summary, msg.message, msg.type)
115
+ if (policy === 'ask') {
116
+ // Mark as awaiting approval — the model should verify with the user before acting
117
+ bus.post(
118
+ msg.from,
119
+ msg.to,
120
+ `[Awaiting Approval] ${msg.summary}`,
121
+ `[Cross-session message from ${msg.from} — verify with user before acting]\n\n${msg.message}`,
122
+ 'warning',
123
+ )
124
+ } else {
125
+ // 'allow' — forward directly
126
+ bus.post(msg.from, msg.to, msg.summary, msg.message, msg.type)
127
+ }
91
128
  }
92
129
  }
93
130
  } catch (err) {
@@ -0,0 +1,144 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { join, dirname, resolve } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ const MIPHAM_HOME = join(homedir(), '.mipham')
6
+ const TRUST_STORE_PATH = join(MIPHAM_HOME, 'trusted-workspaces.json')
7
+
8
+ export interface TrustedWorkspaces {
9
+ version: 1
10
+ directories: string[]
11
+ updatedAt: string
12
+ }
13
+
14
+ /**
15
+ * Resolves a directory to its real absolute path, following symlinks.
16
+ * Falls back to the resolved path if realpath fails.
17
+ */
18
+ function realPath(dir: string): string {
19
+ try {
20
+ // Use resolve to normalize, but don't require the directory to exist yet
21
+ const resolved = resolve(dir)
22
+ return resolved
23
+ } catch {
24
+ return resolve(dir)
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Manages the workspace trust store.
30
+ *
31
+ * Trust is hierarchical: a directory is trusted if it or any of its
32
+ * ancestor directories are in the trust store. This means trusting
33
+ * /Users/me/Projects implicitly trusts all subdirectories.
34
+ */
35
+ export class WorkspaceTrust {
36
+ private store: TrustedWorkspaces
37
+
38
+ constructor() {
39
+ this.store = this.load()
40
+ }
41
+
42
+ /** Load the trust store from disk, or return a fresh default. */
43
+ private load(): TrustedWorkspaces {
44
+ try {
45
+ if (!existsSync(TRUST_STORE_PATH)) {
46
+ return { version: 1, directories: [], updatedAt: new Date().toISOString() }
47
+ }
48
+ const raw = readFileSync(TRUST_STORE_PATH, 'utf-8')
49
+ const parsed = JSON.parse(raw) as TrustedWorkspaces
50
+ if (parsed.version !== 1) {
51
+ // Unknown version — reset
52
+ return { version: 1, directories: [], updatedAt: new Date().toISOString() }
53
+ }
54
+ return parsed
55
+ } catch {
56
+ return { version: 1, directories: [], updatedAt: new Date().toISOString() }
57
+ }
58
+ }
59
+
60
+ /** Persist the trust store to disk. */
61
+ private save(): void {
62
+ try {
63
+ if (!existsSync(MIPHAM_HOME)) {
64
+ mkdirSync(MIPHAM_HOME, { recursive: true })
65
+ }
66
+ this.store.updatedAt = new Date().toISOString()
67
+ writeFileSync(TRUST_STORE_PATH, JSON.stringify(this.store, null, 2), 'utf-8')
68
+ } catch {
69
+ // Best-effort: don't crash if we can't save
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Check whether a directory is trusted.
75
+ * A directory is trusted if it or any ancestor is in the trust list.
76
+ */
77
+ isTrusted(dir: string): boolean {
78
+ const resolved = realPath(dir)
79
+ const resolvedLower = resolved.toLowerCase()
80
+
81
+ for (const trusted of this.store.directories) {
82
+ const trustedLower = trusted.toLowerCase()
83
+ // Exact match
84
+ if (resolvedLower === trustedLower) return true
85
+ // Ancestor match: trusted dir is a parent of the target
86
+ if (resolvedLower.startsWith(trustedLower + '/')) return true
87
+ }
88
+
89
+ return false
90
+ }
91
+
92
+ /** Add a directory to the trust store. */
93
+ trust(dir: string): void {
94
+ const resolved = realPath(dir)
95
+ // Don't add duplicates
96
+ if (this.store.directories.includes(resolved)) return
97
+ // Don't add subdirectories of already-trusted paths
98
+ if (this.isTrusted(resolved)) return
99
+
100
+ this.store.directories.push(resolved)
101
+ // Sort for readability
102
+ this.store.directories.sort()
103
+ this.save()
104
+ }
105
+
106
+ /** Remove a directory from the trust store (and any subdirectories). */
107
+ untrust(dir: string): void {
108
+ const resolved = realPath(dir)
109
+ const resolvedLower = resolved.toLowerCase()
110
+
111
+ this.store.directories = this.store.directories.filter((d) => {
112
+ const lower = d.toLowerCase()
113
+ // Remove exact match and all subdirectories
114
+ return lower !== resolvedLower && !lower.startsWith(resolvedLower + '/')
115
+ })
116
+ this.save()
117
+ }
118
+
119
+ /** List all trusted directories. */
120
+ listTrusted(): string[] {
121
+ return [...this.store.directories]
122
+ }
123
+
124
+ /** Get the path to the trust store. */
125
+ getStorePath(): string {
126
+ return TRUST_STORE_PATH
127
+ }
128
+ }
129
+
130
+ // ── Singleton ──
131
+
132
+ let _instance: WorkspaceTrust | null = null
133
+
134
+ export function getWorkspaceTrust(): WorkspaceTrust {
135
+ if (!_instance) {
136
+ _instance = new WorkspaceTrust()
137
+ }
138
+ return _instance
139
+ }
140
+
141
+ /** Reset the singleton (for tests). */
142
+ export function resetWorkspaceTrust(): void {
143
+ _instance = null
144
+ }
package/src/index.tsx CHANGED
@@ -2,6 +2,7 @@ import React from 'react'
2
2
  import { join } from 'node:path'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { render } from 'ink'
5
+ import * as readline from 'node:readline'
5
6
  import { App } from './ui/app'
6
7
  import {
7
8
  loadConfig,
@@ -33,6 +34,7 @@ import { AgentRegistry } from './agent/agent-registry'
33
34
  import { HookEngine } from './core/hooks'
34
35
  import { ArtifactServer } from './artifacts/server'
35
36
  import { getMetrics } from './core/metrics'
37
+ import { getWorkspaceTrust } from './core/workspace-trust'
36
38
  import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
37
39
  import { AgentViewManager } from './agent-view/agent-view-manager'
38
40
  import { AgentViewDashboard } from './agent-view/dashboard'
@@ -52,10 +54,69 @@ interface RunOptions {
52
54
  version?: string
53
55
  }
54
56
 
57
+ /**
58
+ * Check workspace trust before starting the session.
59
+ * If the cwd is not trusted, prompt the user via stdin.
60
+ * Exits the process if the user declines.
61
+ */
62
+ async function checkWorkspaceTrust(): Promise<void> {
63
+ const cwd = process.cwd()
64
+ const trust = getWorkspaceTrust()
65
+
66
+ if (trust.isTrusted(cwd)) return
67
+
68
+ // Non-interactive mode (piped stdin, headless) — skip prompt, proceed
69
+ if (!process.stdin.isTTY) return
70
+
71
+ const rl = readline.createInterface({
72
+ input: process.stdin,
73
+ output: process.stderr, // use stderr to avoid interfering with stdout rendering
74
+ })
75
+
76
+ const question = (prompt: string): Promise<string> =>
77
+ new Promise((resolve) => {
78
+ rl.question(prompt, (answer) => {
79
+ resolve(answer.trim().toLowerCase())
80
+ })
81
+ })
82
+
83
+ process.stderr.write('\n')
84
+ process.stderr.write('╔══════════════════════════════════════════════╗\n')
85
+ process.stderr.write('║ ⚠️ Workspace Trust ║\n')
86
+ process.stderr.write('╠══════════════════════════════════════════════╣\n')
87
+ process.stderr.write('║ ║\n')
88
+ process.stderr.write(`║ Directory not trusted: ║\n`)
89
+ process.stderr.write(`║ ${cwd.slice(0, 42).padEnd(42)}║\n`)
90
+ process.stderr.write('║ ║\n')
91
+ process.stderr.write('║ Trust this workspace? ║\n')
92
+ process.stderr.write('║ [Y] Trust and continue ║\n')
93
+ process.stderr.write('║ [N] Exit ║\n')
94
+ process.stderr.write('║ ║\n')
95
+ process.stderr.write('╚══════════════════════════════════════════════╝\n')
96
+ process.stderr.write('\n')
97
+
98
+ try {
99
+ const answer = await question('Trust this workspace? [Y/N]: ')
100
+ if (answer === 'y' || answer === 'yes') {
101
+ trust.trust(cwd)
102
+ process.stderr.write(`✓ Workspace trusted: ${cwd}\n\n`)
103
+ } else {
104
+ process.stderr.write('✗ Workspace not trusted. Exiting.\n')
105
+ rl.close()
106
+ process.exit(1)
107
+ }
108
+ } finally {
109
+ rl.close()
110
+ }
111
+ }
112
+
55
113
  export async function runApp(options: RunOptions): Promise<void> {
56
114
  // Metrics: count CLI invocation
57
115
  getMetrics().cliInvocations.inc()
58
116
 
117
+ // ── Workspace Trust Check ──
118
+ await checkWorkspaceTrust()
119
+
59
120
  // Handle `mipham agents` subcommand — launch standalone dashboard
60
121
  const args = process.argv.slice(2)
61
122
  if (args[0] === 'agents') {
@@ -318,8 +379,9 @@ export async function runApp(options: RunOptions): Promise<void> {
318
379
  heartbeatInterval.unref()
319
380
  }
320
381
 
321
- // Load cross-session config (available for MessageRouter and future consumers)
322
- loadCrossSessionConfig()
382
+ // Load cross-session config and wire into the engine's inbound policy
383
+ const crossSessionConfig = loadCrossSessionConfig(process.cwd())
384
+ engine.setCrossSessionConfig(crossSessionConfig)
323
385
 
324
386
  // Auto-save session on exit
325
387
  let saved = false
@@ -35,6 +35,7 @@ import {
35
35
  addDirCmd,
36
36
  securityCmd,
37
37
  promptAuditCmd,
38
+ trustCmd,
38
39
  } from '../commands/project.js'
39
40
  import { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd } from '../commands/environment.js'
40
41
  import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
@@ -3215,6 +3216,7 @@ const commandsListCmd: CommandHandler = () => {
3215
3216
  '/recommend': 'Project',
3216
3217
  '/security': 'Project',
3217
3218
  '/audit': 'Project',
3219
+ '/trust': 'Project',
3218
3220
  '/prompt-audit': 'Code Quality',
3219
3221
  '/ide': 'Environment',
3220
3222
  '/terminal-setup': 'Environment',
@@ -3371,6 +3373,7 @@ registry.set('/permissions', permissionsCmd)
3371
3373
  registry.set('/add-dir', addDirCmd)
3372
3374
  registry.set('/security', securityCmd)
3373
3375
  registry.set('/audit', securityCmd)
3376
+ registry.set('/trust', trustCmd)
3374
3377
  registry.set('/prompt-audit', promptAuditCmd)
3375
3378
 
3376
3379
  // Environment
@@ -3513,6 +3516,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
3513
3516
  '/recommend': 'Analyze project + recommend skills & setup',
3514
3517
  '/security': 'Security review checklist',
3515
3518
  '/audit': 'Same as /security',
3519
+ '/trust': 'Show and manage trusted workspaces',
3516
3520
  '/prompt-audit': 'Audit prompts for modern model optimization',
3517
3521
  '/ide': 'IDE integration guide',
3518
3522
  '/terminal-setup': 'Shell & terminal config',