@miphamai/cli 0.17.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,94 @@
1
+ import type { CommandHandler } from '../ui/commands'
2
+ import { KeyManager } from '../config/keys-manager'
3
+
4
+ export const keysCmd: CommandHandler = async (_ctx, args) => {
5
+ const manager = new KeyManager()
6
+ const sub = args[0]?.toLowerCase()
7
+
8
+ // /keys rotate <provider>
9
+ if (sub === 'rotate') {
10
+ const provider = args[1]
11
+ if (!provider) {
12
+ return {
13
+ content: 'Usage: /keys rotate <provider>\n\nExample: /keys rotate deepseek',
14
+ }
15
+ }
16
+ // Interactive: prompt user to provide new key via follow-up chat message
17
+ return {
18
+ content: [
19
+ '── Key Rotation ──',
20
+ '',
21
+ `Provider: ${provider}`,
22
+ '',
23
+ 'To complete rotation, the new API key must be provided.',
24
+ 'Type your new key in chat and the AI will handle rotation securely.',
25
+ '',
26
+ 'The old key will be backed up to ~/.mipham/keys/<provider>.backup (chmod 600).',
27
+ ].join('\n'),
28
+ forwardToAI: `The user wants to rotate the API key for provider "${provider}". Ask them to provide the new key value, then:
29
+ 1. Call KeyManager.rotate("${provider}", newKey) with the provided key
30
+ 2. The new key should be stored in ~/.mipham/config.json (or appropriate config)
31
+ 3. Confirm rotation count and backup location
32
+ 4. Remind them the old key is backed up at ~/.mipham/keys/${provider}.backup`,
33
+ }
34
+ }
35
+
36
+ // /keys audit
37
+ if (sub === 'audit') {
38
+ const expired = manager.audit()
39
+ if (expired.length === 0) {
40
+ return {
41
+ content:
42
+ '── Key Audit ──\n\n✅ All keys are within the 90-day rotation window.\n\nNo expired keys found.',
43
+ }
44
+ }
45
+
46
+ const lines: string[] = [
47
+ '── Key Audit ──',
48
+ '',
49
+ `⚠️ ${expired.length} key(s) have exceeded the 90-day rotation threshold:`,
50
+ '',
51
+ ...expired.map(
52
+ (k) =>
53
+ ` 🔴 ${k.provider.padEnd(16)} ${k.ageDays} days since last rotation (${k.lastRotated.slice(0, 10)})`,
54
+ ),
55
+ '',
56
+ 'Rotate expired keys with: /keys rotate <provider>',
57
+ ]
58
+ return { content: lines.join('\n') }
59
+ }
60
+
61
+ // /keys (list)
62
+ const keys = manager.list()
63
+
64
+ if (keys.length === 0) {
65
+ return {
66
+ content: [
67
+ '── API Keys ──',
68
+ '',
69
+ 'No API keys tracked yet.',
70
+ '',
71
+ 'Keys are tracked in ~/.mipham/keys.json when you configure providers.',
72
+ 'Use /keys rotate <provider> to begin tracking rotation for a provider.',
73
+ ].join('\n'),
74
+ }
75
+ }
76
+
77
+ const lines: string[] = [
78
+ '── API Keys ──',
79
+ '',
80
+ 'Provider Age Rotations Status',
81
+ '─'.repeat(55),
82
+ ...keys.map((k) => {
83
+ const status = k.expired ? '🔴 EXPIRED' : '🟢 OK'
84
+ return ` ${k.provider.padEnd(16)} ${String(k.ageDays).padStart(4)}d ${String(k.rotationCount).padStart(5)} ${status}`
85
+ }),
86
+ '',
87
+ `${keys.length} key(s) tracked.`,
88
+ '',
89
+ 'Commands:',
90
+ ' /keys rotate <provider> — rotate a key',
91
+ ' /keys audit — check for expired keys',
92
+ ]
93
+ return { content: lines.join('\n') }
94
+ }
@@ -0,0 +1,121 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ const MIPHAM_DIR = join(homedir(), '.mipham')
6
+ const KEYS_FILE = join(MIPHAM_DIR, 'keys.json')
7
+
8
+ export interface KeyEntry {
9
+ createdAt: string
10
+ lastRotated: string
11
+ rotationCount: number
12
+ provider: string
13
+ }
14
+
15
+ export interface KeyStatus {
16
+ provider: string
17
+ createdAt: string
18
+ lastRotated: string
19
+ rotationCount: number
20
+ ageDays: number
21
+ expired: boolean
22
+ }
23
+
24
+ export interface KeysData {
25
+ [provider: string]: KeyEntry
26
+ }
27
+
28
+ function loadKeys(): KeysData {
29
+ if (!existsSync(KEYS_FILE)) return {}
30
+ try {
31
+ const raw = readFileSync(KEYS_FILE, 'utf-8')
32
+ return JSON.parse(raw) as KeysData
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+
38
+ function saveKeys(data: KeysData): void {
39
+ mkdirSync(dirname(KEYS_FILE), { recursive: true })
40
+ const tmp = KEYS_FILE + '.tmp'
41
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
42
+ writeFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
43
+ try {
44
+ chmodSync(KEYS_FILE, 0o600)
45
+ } catch {
46
+ // chmod on Windows is a no-op
47
+ }
48
+ }
49
+
50
+ function daysSince(iso: string): number {
51
+ const then = new Date(iso).getTime()
52
+ const now = Date.now()
53
+ return Math.floor((now - then) / (1000 * 60 * 60 * 24))
54
+ }
55
+
56
+ export class KeyManager {
57
+ list(): KeyStatus[] {
58
+ const data = loadKeys()
59
+ return Object.entries(data).map(([provider, entry]) => ({
60
+ provider,
61
+ createdAt: entry.createdAt,
62
+ lastRotated: entry.lastRotated,
63
+ rotationCount: entry.rotationCount,
64
+ ageDays: daysSince(entry.lastRotated),
65
+ expired: daysSince(entry.lastRotated) > 90,
66
+ }))
67
+ }
68
+
69
+ rotate(provider: string, _newKey: string): { success: boolean; message: string } {
70
+ const data = loadKeys()
71
+ const now = new Date().toISOString()
72
+ const existing = data[provider]
73
+
74
+ // Backup old entry if it exists
75
+ if (existing) {
76
+ const backupDir = join(MIPHAM_DIR, 'keys')
77
+ mkdirSync(backupDir, { recursive: true })
78
+ const backupPath = join(backupDir, `${provider}.backup`)
79
+ writeFileSync(backupPath, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 })
80
+ try {
81
+ chmodSync(backupPath, 0o600)
82
+ } catch {
83
+ // Windows
84
+ }
85
+
86
+ data[provider] = {
87
+ createdAt: existing.createdAt,
88
+ lastRotated: now,
89
+ rotationCount: existing.rotationCount + 1,
90
+ provider,
91
+ }
92
+ } else {
93
+ data[provider] = {
94
+ createdAt: now,
95
+ lastRotated: now,
96
+ rotationCount: 1,
97
+ provider,
98
+ }
99
+ }
100
+
101
+ saveKeys(data)
102
+ return {
103
+ success: true,
104
+ message: `Key for "${provider}" rotated (rotation #${data[provider]!.rotationCount}). Backup saved to ~/.mipham/keys/${provider}.backup`,
105
+ }
106
+ }
107
+
108
+ audit(): KeyStatus[] {
109
+ return this.list().filter((k) => k.expired)
110
+ }
111
+
112
+ getExpiryReminder(): string | null {
113
+ const expired = this.audit()
114
+ if (expired.length === 0) return null
115
+
116
+ const lines = expired.map(
117
+ (k) => ` - ${k.provider}: last rotated ${k.ageDays} days ago (90-day threshold exceeded)`,
118
+ )
119
+ return `⚠️ API key rotation overdue:\n${lines.join('\n')}\n\nRun /keys rotate <provider> to rotate.`
120
+ }
121
+ }
package/src/index.tsx CHANGED
@@ -153,6 +153,15 @@ export async function runApp(options: RunOptions): Promise<void> {
153
153
  if (latestSession?.summary) {
154
154
  prompt = `${prompt}\n\n<system-context name="previous-session">\n# Previous Session Summary\n${latestSession.summary}\n</system-context>`
155
155
  }
156
+
157
+ // Key expiry reminder (Task 7)
158
+ const { KeyManager } = await import('./config/keys-manager')
159
+ const keyManager = new KeyManager()
160
+ const keyReminder = keyManager.getExpiryReminder()
161
+ if (keyReminder) {
162
+ prompt = `${prompt}\n\n<system-reminder name="key-expiry">\n${keyReminder}\n</system-reminder>`
163
+ }
164
+
156
165
  context.setSystemPrompt(prompt)
157
166
  }
158
167
 
@@ -0,0 +1,93 @@
1
+ export interface GateResult {
2
+ blocked: boolean
3
+ reason?: string
4
+ }
5
+
6
+ const PROMPT_INJECTION_PATTERNS: Array<{ regex: RegExp; label: string }> = [
7
+ {
8
+ regex: /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?)/i,
9
+ label: 'ignore-previous-instructions',
10
+ },
11
+ {
12
+ regex: /^system\s*:\s*(now\s+)?(act|pretend|you\s+are)/im,
13
+ label: 'role-impersonation',
14
+ },
15
+ {
16
+ regex: /(^|\n)(---\s*BEGIN|<\|\w+\|>)/,
17
+ label: 'delimiter-injection',
18
+ },
19
+ {
20
+ regex: /you\s+are\s+now\s+(dan|jailbroken|unrestricted)/i,
21
+ label: 'dan-jailbreak',
22
+ },
23
+ {
24
+ regex:
25
+ /(disregard|override|supersede)\s+(all\s+)?(previous|prior|above|system)\s+(instructions?|rules?|prompts?)/i,
26
+ label: 'override-instructions',
27
+ },
28
+ ]
29
+
30
+ const DANGEROUS_BASH_PATTERNS: Array<{ regex: RegExp; label: string }> = [
31
+ { regex: /\$\(.+\)/, label: 'command-substitution' },
32
+ { regex: /`[^`]+`/, label: 'backtick-substitution' },
33
+ { regex: /;\s*(rm|cat|sh|bash)\b/, label: 'command-chain-injection' },
34
+ { regex: /curl.+\|\s*(sh|bash)\b/, label: 'curl-pipe-shell' },
35
+ { regex: /\|\s*(sh|bash)\b/, label: 'pipe-to-shell' },
36
+ { regex: />\s*\/dev\//, label: 'redirect-to-dev' },
37
+ ]
38
+
39
+ const API_KEY_PATTERNS: Array<{ regex: RegExp; label: string }> = [
40
+ { regex: /sk-ant-[a-zA-Z0-9_-]{20,}/, label: 'anthropic-key' },
41
+ { regex: /sk-[a-zA-Z0-9]{32,}/, label: 'openai-key' },
42
+ { regex: /eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{10,}/, label: 'jwt-token' },
43
+ { regex: /x-api-key:\s*[a-zA-Z0-9_-]{20,}/i, label: 'api-key-header' },
44
+ ]
45
+
46
+ export class SecurityGate {
47
+ static checkPromptInjection(input: string): GateResult {
48
+ if (!input || input.length < 10) return { blocked: false }
49
+ for (const pattern of PROMPT_INJECTION_PATTERNS) {
50
+ if (pattern.regex.test(input)) {
51
+ return { blocked: true, reason: `prompt injection detected: ${pattern.label}` }
52
+ }
53
+ }
54
+ return { blocked: false }
55
+ }
56
+
57
+ static checkPathTraversal(path: string, _cwd: string): GateResult {
58
+ if (!path) return { blocked: false }
59
+ if (/\0/.test(path)) {
60
+ return { blocked: true, reason: 'null byte in path' }
61
+ }
62
+ if (/%25|%2e%2e/i.test(path)) {
63
+ return { blocked: true, reason: 'double-encoded path traversal' }
64
+ }
65
+ const segments = path.replace(/\\/g, '/').split('/')
66
+ for (const seg of segments) {
67
+ if (seg === '..') {
68
+ return { blocked: true, reason: 'path traversal attempt (..)' }
69
+ }
70
+ }
71
+ return { blocked: false }
72
+ }
73
+
74
+ static checkBashCommand(command: string): GateResult {
75
+ if (!command) return { blocked: false }
76
+ for (const pattern of DANGEROUS_BASH_PATTERNS) {
77
+ if (pattern.regex.test(command)) {
78
+ return { blocked: true, reason: `dangerous bash pattern: ${pattern.label}` }
79
+ }
80
+ }
81
+ return { blocked: false }
82
+ }
83
+
84
+ static checkCredentialLeak(output: string): GateResult {
85
+ if (!output || output.length < 10) return { blocked: false }
86
+ for (const pattern of API_KEY_PATTERNS) {
87
+ if (pattern.regex.test(output)) {
88
+ return { blocked: true, reason: `credential leak detected: ${pattern.label}` }
89
+ }
90
+ }
91
+ return { blocked: false }
92
+ }
93
+ }
@@ -24,6 +24,7 @@ import {
24
24
  } from '../commands/project.js'
25
25
  import { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd } from '../commands/environment.js'
26
26
  import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
27
+ import { keysCmd } from '../commands/keys'
27
28
 
28
29
  export interface CommandContext {
29
30
  engine: QueryEngine
@@ -187,6 +188,9 @@ const helpCmd: CommandHandler = (ctx) => {
187
188
  ── Account ─────────────────────────
188
189
  /login Show API key status
189
190
  /logout Clear credentials guide
191
+ /keys List API key rotation status
192
+ /keys rotate Rotate an API key
193
+ /keys audit Check for expired keys
190
194
  /feedback Send feedback
191
195
 
192
196
  ── Agents ──────────────────────────
@@ -3122,6 +3126,9 @@ const commandsListCmd: CommandHandler = () => {
3122
3126
  '/release-notes': 'Environment',
3123
3127
  '/login': 'Account',
3124
3128
  '/logout': 'Account',
3129
+ '/keys': 'Account',
3130
+ '/keys rotate': 'Account',
3131
+ '/keys audit': 'Account',
3125
3132
  '/feedback': 'Account',
3126
3133
  '/agents': 'Agents',
3127
3134
  '/bg': 'Agents',
@@ -3273,6 +3280,9 @@ registry.set('/release-notes', releaseNotesCmd)
3273
3280
  registry.set('/mcp', mcpCmd)
3274
3281
  registry.set('/login', loginCmd)
3275
3282
  registry.set('/logout', logoutCmd)
3283
+ registry.set('/keys', keysCmd)
3284
+ registry.set('/keys rotate', keysCmd)
3285
+ registry.set('/keys audit', keysCmd)
3276
3286
  registry.set('/feedback', feedbackCmd)
3277
3287
  registry.set('/agents', agentsCmd)
3278
3288
  registry.set('/bg', bgCmd)
@@ -3399,6 +3409,9 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
3399
3409
  '/upgrade': 'Show upgrade instructions',
3400
3410
  '/login': 'Show API key status',
3401
3411
  '/logout': 'Clear credentials guide',
3412
+ '/keys': 'List API key rotation status',
3413
+ '/keys rotate': 'Rotate an API key',
3414
+ '/keys audit': 'Check for expired keys',
3402
3415
  '/feedback': 'Send feedback',
3403
3416
  '/agents': 'Agent view dashboard',
3404
3417
  '/bg': 'Run a background agent task',