11// src/ui/native.ts
2- import { spawn } from 'child_process' ;
2+ import { spawn , ChildProcess } from 'child_process' ; // 1. Added ChildProcess import
3+ import chalk from 'chalk' ;
34
45const isTestEnv = ( ) => {
56 return (
@@ -13,122 +14,124 @@ const isTestEnv = () => {
1314} ;
1415
1516/**
16- * Sends a non-blocking, one-way system notification .
17+ * Truncates long strings by keeping the start and end .
1718 */
19+ function smartTruncate ( str : string , maxLen : number = 500 ) : string {
20+ if ( str . length <= maxLen ) return str ;
21+ const edge = Math . floor ( maxLen / 2 ) - 3 ;
22+ return `${ str . slice ( 0 , edge ) } ... ${ str . slice ( - edge ) } ` ;
23+ }
24+
25+ function formatArgs ( args : unknown ) : string {
26+ if ( args === null || args === undefined ) return '(none)' ;
27+
28+ let parsed = args ;
29+
30+ // 1. EXTRA STEP: If args is a string, try to see if it's nested JSON
31+ // Gemini often wraps the command inside a stringified JSON object
32+ if ( typeof args === 'string' ) {
33+ const trimmed = args . trim ( ) ;
34+ if ( trimmed . startsWith ( '{' ) && trimmed . endsWith ( '}' ) ) {
35+ try {
36+ parsed = JSON . parse ( trimmed ) ;
37+ } catch {
38+ parsed = args ;
39+ }
40+ } else {
41+ return smartTruncate ( args , 600 ) ;
42+ }
43+ }
44+
45+ // 2. Now handle the object (whether it was passed as one or parsed above)
46+ if ( typeof parsed === 'object' && ! Array . isArray ( parsed ) ) {
47+ const obj = parsed as Record < string , unknown > ;
48+
49+ const codeKeys = [
50+ 'command' ,
51+ 'cmd' ,
52+ 'shell_command' ,
53+ 'bash_command' ,
54+ 'script' ,
55+ 'code' ,
56+ 'input' ,
57+ 'sql' ,
58+ 'query' ,
59+ 'arguments' ,
60+ 'args' ,
61+ 'param' ,
62+ 'params' ,
63+ 'text' ,
64+ ] ;
65+ const foundKey = Object . keys ( obj ) . find ( ( k ) => codeKeys . includes ( k . toLowerCase ( ) ) ) ;
66+
67+ if ( foundKey ) {
68+ const val = obj [ foundKey ] ;
69+ const str = typeof val === 'string' ? val : JSON . stringify ( val ) ;
70+ // Visual improvement: add a label so you know what you are looking at
71+ return `[${ foundKey . toUpperCase ( ) } ]:\n${ smartTruncate ( str , 500 ) } ` ;
72+ }
73+
74+ return Object . entries ( obj )
75+ . slice ( 0 , 5 )
76+ . map (
77+ ( [ k , v ] ) => ` ${ k } : ${ smartTruncate ( typeof v === 'string' ? v : JSON . stringify ( v ) , 300 ) } `
78+ )
79+ . join ( '\n' ) ;
80+ }
81+
82+ return smartTruncate ( JSON . stringify ( parsed ) , 200 ) ;
83+ }
84+
1885export function sendDesktopNotification ( title : string , body : string ) : void {
1986 if ( isTestEnv ( ) ) return ;
20-
2187 try {
22- const safeTitle = title . replace ( / " / g, '\\"' ) ;
23- const safeBody = body . replace ( / " / g, '\\"' ) ;
24-
2588 if ( process . platform === 'darwin' ) {
26- const script = `display notification "${ safeBody } " with title "${ safeTitle } "` ;
89+ const script = `display notification "${ body . replace ( / " / g , '\\"' ) } " with title "${ title . replace ( / " / g , '\\"' ) } "` ;
2790 spawn ( 'osascript' , [ '-e' , script ] , { detached : true , stdio : 'ignore' } ) . unref ( ) ;
2891 } else if ( process . platform === 'linux' ) {
29- spawn ( 'notify-send' , [ safeTitle , safeBody , '--icon=dialog-warning' ] , {
92+ spawn ( 'notify-send' , [ title , body , '--icon=dialog-warning' ] , {
3093 detached : true ,
3194 stdio : 'ignore' ,
3295 } ) . unref ( ) ;
3396 }
3497 } catch {
35- /* Silent fail for notifications */
36- }
37- }
38-
39- /**
40- * Formats tool arguments into readable key: value lines.
41- * Each value is truncated to avoid overwhelming the popup.
42- */
43- function formatArgs ( args : unknown ) : string {
44- if ( args === null || args === undefined ) return '(none)' ;
45-
46- if ( typeof args !== 'object' || Array . isArray ( args ) ) {
47- const str = typeof args === 'string' ? args : JSON . stringify ( args ) ;
48- return str . length > 200 ? str . slice ( 0 , 200 ) + '…' : str ;
49- }
50-
51- const entries = Object . entries ( args as Record < string , unknown > ) . filter (
52- ( [ , v ] ) => v !== null && v !== undefined && v !== ''
53- ) ;
54-
55- if ( entries . length === 0 ) return '(none)' ;
56-
57- const MAX_FIELDS = 5 ;
58- const MAX_VALUE_LEN = 120 ;
59-
60- const lines = entries . slice ( 0 , MAX_FIELDS ) . map ( ( [ key , val ] ) => {
61- const str = typeof val === 'string' ? val : JSON . stringify ( val ) ;
62- const truncated = str . length > MAX_VALUE_LEN ? str . slice ( 0 , MAX_VALUE_LEN ) + '…' : str ;
63- return ` ${ key } : ${ truncated } ` ;
64- } ) ;
65-
66- if ( entries . length > MAX_FIELDS ) {
67- lines . push ( ` … and ${ entries . length - MAX_FIELDS } more field(s)` ) ;
98+ /* ignore */
6899 }
69-
70- return lines . join ( '\n' ) ;
71100}
72101
73- /**
74- * Triggers an asynchronous, two-way OS dialog box.
75- * Returns: 'allow' | 'deny' | 'always_allow'
76- */
77102export async function askNativePopup (
78103 toolName : string ,
79104 args : unknown ,
80105 agent ?: string ,
81106 explainableLabel ?: string ,
82- locked : boolean = false , // Phase 4.1: The Remote Lock
83- signal ?: AbortSignal // Phase 4.2: The Auto-Close Trigger
107+ locked : boolean = false ,
108+ signal ?: AbortSignal
84109) : Promise < 'allow' | 'deny' | 'always_allow' > {
85110 if ( isTestEnv ( ) ) return 'deny' ;
86- if ( process . env . NODE9_DEBUG === '1' || process . env . VITEST ) {
87- console . log ( `[DEBUG Native] askNativePopup called for: ${ toolName } ` ) ;
88- console . log ( `[DEBUG Native] isTestEnv check:` , {
89- VITEST : process . env . VITEST ,
90- NODE_ENV : process . env . NODE_ENV ,
91- CI : process . env . CI ,
92- isTest : isTestEnv ( ) ,
93- } ) ;
94- }
95111
96- const title = locked
97- ? `⚡ Node9 — Locked by Admin Policy`
98- : `🛡️ Node9 — Action Requires Approval` ;
112+ const formattedArgs = formatArgs ( args ) ;
113+ const title = locked ? `⚡ Node9 — Locked` : `🛡️ Node9 — Action Approval` ;
99114
100- // Build a structured, scannable message
101115 let message = '' ;
116+ if ( locked ) message += `⚠️ LOCKED BY ADMIN POLICY\n` ;
117+ message += `Tool: ${ toolName } \n` ;
118+ message += `Agent: ${ agent || 'AI Agent' } \n` ;
119+ message += `Rule: ${ explainableLabel || 'Security Policy' } \n\n` ;
120+ message += `${ formattedArgs } ` ;
102121
103- if ( locked ) {
104- message += `⚡ Awaiting remote approval via Slack. Local override is disabled.\n` ;
105- message += `─────────────────────────────────\n` ;
106- }
107-
108- message += `Tool: ${ toolName } \n` ;
109- message += `Agent: ${ agent || 'AI Agent' } \n` ;
110- if ( explainableLabel ) {
111- message += `Reason: ${ explainableLabel } \n` ;
112- }
113- message += `\nArguments:\n${ formatArgs ( args ) } ` ;
114-
115- if ( ! locked ) {
116- message += `\n\nEnter = Allow | Click "Block" to deny` ;
117- }
118-
119- // Escape for shell/applescript safety
120- const safeMessage = message . replace ( / \\ / g, '\\\\' ) . replace ( / " / g, '\\"' ) . replace ( / ` / g, "'" ) ;
121- const safeTitle = title . replace ( / " / g, '\\"' ) ;
122+ process . stderr . write ( chalk . yellow ( `\n🛡️ Node9: Intercepted "${ toolName } " — awaiting user...\n` ) ) ;
122123
123124 return new Promise ( ( resolve ) => {
124- let childProcess : ReturnType < typeof spawn > | null = null ;
125+ // 2. FIXED: Use ChildProcess type instead of any
126+ let childProcess : ChildProcess | null = null ;
125127
126- // The Auto-Close Logic (Fires when Cloud wins the race)
127128 const onAbort = ( ) => {
128- if ( childProcess ) {
129+ if ( childProcess && childProcess . pid ) {
129130 try {
130- process . kill ( childProcess . pid ! , 'SIGKILL' ) ;
131- } catch { }
131+ process . kill ( childProcess . pid , 'SIGKILL' ) ;
132+ } catch {
133+ /* ignore */
134+ }
132135 }
133136 resolve ( 'deny' ) ;
134137 } ;
@@ -138,103 +141,51 @@ export async function askNativePopup(
138141 signal . addEventListener ( 'abort' , onAbort ) ;
139142 }
140143
141- const cleanup = ( ) => {
142- if ( signal ) signal . removeEventListener ( 'abort' , onAbort ) ;
143- } ;
144-
145144 try {
146- // --- macOS ---
147145 if ( process . platform === 'darwin' ) {
148- // Default button is "Allow" — Enter = permit, Escape = Block
149146 const buttons = locked
150147 ? `buttons {"Waiting…"} default button "Waiting…"`
151148 : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"` ;
152-
153- const script = `
154- tell application "System Events"
155- activate
156- display dialog "${ safeMessage } " with title "${ safeTitle } " ${ buttons }
157- end tell` ;
158-
159- childProcess = spawn ( 'osascript' , [ '-e' , script ] ) ;
160- let output = '' ;
161- childProcess . stdout ?. on ( 'data' , ( d ) => ( output += d . toString ( ) ) ) ;
162-
163- childProcess . on ( 'close' , ( code ) => {
164- cleanup ( ) ;
165- if ( locked ) return resolve ( 'deny' ) ;
166- if ( code === 0 ) {
167- if ( output . includes ( 'Always Allow' ) ) return resolve ( 'always_allow' ) ;
168- if ( output . includes ( 'Allow' ) ) return resolve ( 'allow' ) ;
169- }
170- resolve ( 'deny' ) ;
171- } ) ;
172- }
173-
174- // --- Linux ---
175- else if ( process . platform === 'linux' ) {
176- const argsList = locked
177- ? [
178- '--info' ,
179- '--title' ,
180- title ,
181- '--text' ,
182- safeMessage ,
183- '--ok-label' ,
184- 'Waiting for Slack…' ,
185- '--timeout' ,
186- '300' ,
187- ]
188- : [
189- '--question' ,
190- '--title' ,
191- title ,
192- '--text' ,
193- safeMessage ,
194- '--ok-label' ,
195- 'Allow' ,
196- '--cancel-label' ,
197- 'Block' ,
198- '--extra-button' ,
199- 'Always Allow' ,
200- '--timeout' ,
201- '300' ,
202- ] ;
203-
149+ const script = `on run argv\ntell application "System Events"\nactivate\ndisplay dialog (item 1 of argv) with title (item 2 of argv) ${ buttons } \nend tell\nend run` ;
150+ childProcess = spawn ( 'osascript' , [ '-e' , script , '--' , message , title ] ) ;
151+ } else if ( process . platform === 'linux' ) {
152+ const argsList = [
153+ locked ? '--info' : '--question' ,
154+ '--modal' ,
155+ '--width=450' ,
156+ '--title' ,
157+ title ,
158+ '--text' ,
159+ message ,
160+ '--ok-label' ,
161+ locked ? 'Waiting...' : 'Allow' ,
162+ '--timeout' ,
163+ '300' ,
164+ ] ;
165+ if ( ! locked ) {
166+ argsList . push ( '--cancel-label' , 'Block' ) ;
167+ argsList . push ( '--extra-button' , 'Always Allow' ) ;
168+ }
204169 childProcess = spawn ( 'zenity' , argsList ) ;
205- let output = '' ;
206- childProcess . stdout ?. on ( 'data' , ( d ) => ( output += d . toString ( ) ) ) ;
207-
208- childProcess . on ( 'close' , ( code ) => {
209- cleanup ( ) ;
210- if ( locked ) return resolve ( 'deny' ) ;
211- // zenity: --ok-label (Allow) = exit 0, --cancel-label (Block) = exit 1, extra-button = stdout
212- if ( output . trim ( ) === 'Always Allow' ) return resolve ( 'always_allow' ) ;
213- if ( code === 0 ) return resolve ( 'allow' ) ; // clicked "Allow" (ok-label, Enter)
214- resolve ( 'deny' ) ; // clicked "Block" or timed out
215- } ) ;
170+ } else if ( process . platform === 'win32' ) {
171+ const b64Msg = Buffer . from ( message ) . toString ( 'base64' ) ;
172+ const b64Title = Buffer . from ( title ) . toString ( 'base64' ) ;
173+ const ps = `Add-Type -AssemblyName PresentationFramework; $msg = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${ b64Msg } ")); $title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${ b64Title } ")); $res = [System.Windows.MessageBox]::Show($msg, $title, "${ locked ? 'OK' : 'YesNo' } ", "Warning", "Button2", "DefaultDesktopOnly"); if ($res -eq "Yes") { exit 0 } else { exit 1 }` ;
174+ childProcess = spawn ( 'powershell' , [ '-Command' , ps ] ) ;
216175 }
217176
218- // --- Windows ---
219- else if ( process . platform === 'win32' ) {
220- const buttonType = locked ? 'OK' : 'YesNo' ;
221- const ps = `
222- Add-Type -AssemblyName PresentationFramework;
223- $res = [System.Windows.MessageBox]::Show("${ safeMessage } ", "${ safeTitle } ", "${ buttonType } ", "Warning", "Button2", "DefaultDesktopOnly");
224- if ($res -eq "Yes") { exit 0 } else { exit 1 }` ;
177+ let output = '' ;
178+ // 3. FIXED: Specified Buffer type for stream data
179+ childProcess ?. stdout ?. on ( 'data' , ( d : Buffer ) => ( output += d . toString ( ) ) ) ;
225180
226- childProcess = spawn ( 'powershell' , [ '-Command' , ps ] ) ;
227- childProcess . on ( 'close' , ( code ) => {
228- cleanup ( ) ;
229- if ( locked ) return resolve ( 'deny' ) ;
230- resolve ( code === 0 ? 'allow' : 'deny' ) ;
231- } ) ;
232- } else {
233- cleanup ( ) ;
181+ childProcess ?. on ( 'close' , ( code : number ) => {
182+ if ( signal ) signal . removeEventListener ( 'abort' , onAbort ) ;
183+ if ( locked ) return resolve ( 'deny' ) ;
184+ if ( output . includes ( 'Always Allow' ) ) return resolve ( 'always_allow' ) ;
185+ if ( code === 0 ) return resolve ( 'allow' ) ;
234186 resolve ( 'deny' ) ;
235- }
187+ } ) ;
236188 } catch {
237- cleanup ( ) ;
238189 resolve ( 'deny' ) ;
239190 }
240191 } ) ;
0 commit comments