@miphamai/cli 0.22.0 → 0.24.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 (38) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/workflows/audit.js +66 -18
  4. package/src/agent/agent-context.ts +20 -57
  5. package/src/agent/agent-experience.ts +8 -0
  6. package/src/agent/cross-session/discovery.ts +99 -0
  7. package/src/agent/cross-session/file-inbox.ts +118 -0
  8. package/src/agent/cross-session/transport.ts +17 -0
  9. package/src/agent/effectiveness-tracker.ts +149 -0
  10. package/src/agent/experience-rules.ts +188 -0
  11. package/src/agent/message-router.ts +83 -0
  12. package/src/agent/pattern-analyzer.ts +154 -0
  13. package/src/agent/sub-agent.ts +67 -0
  14. package/src/agent/types.ts +2 -0
  15. package/src/config/defaults.ts +12 -0
  16. package/src/config/loader.ts +33 -0
  17. package/src/core/credential-masker/env-filter.ts +39 -0
  18. package/src/core/credential-masker/index.ts +35 -0
  19. package/src/core/credential-masker/matcher.ts +106 -0
  20. package/src/core/credential-masker/output-scrub.ts +27 -0
  21. package/src/core/credential-masker/pipeline.ts +67 -0
  22. package/src/core/credential-masker/strategies/aws.ts +50 -0
  23. package/src/core/credential-masker/strategies/extract.ts +106 -0
  24. package/src/core/credential-masker/strategies/full.ts +15 -0
  25. package/src/core/credential-masker/strategies/jwt.ts +75 -0
  26. package/src/core/credential-masker/types.ts +16 -0
  27. package/src/core/credential-masker.ts +12 -187
  28. package/src/core/engine.ts +114 -10
  29. package/src/core/rule-engine.ts +179 -0
  30. package/src/core/session-store.ts +15 -2
  31. package/src/index.tsx +47 -2
  32. package/src/shared/types.ts +69 -5
  33. package/src/tools/agent/agent.ts +7 -1
  34. package/src/tools/agent/list-agents.ts +67 -0
  35. package/src/tools/agent/send-message.ts +21 -24
  36. package/src/tools/exec/bash.ts +67 -3
  37. package/src/tools/index.ts +2 -0
  38. package/src/ui/commands.ts +133 -0
@@ -0,0 +1,106 @@
1
+ import { homedir } from 'node:os'
2
+ import type { CredentialMaskingConfig, CredentialFileRule } from '../../shared/index.ts'
3
+
4
+ /**
5
+ * Check if a file path matches any credential file rule.
6
+ * Returns the matching rule, or null if no match.
7
+ */
8
+ export function matchCredentialFile(
9
+ filePath: string,
10
+ config: CredentialMaskingConfig,
11
+ ): CredentialFileRule | null {
12
+ if (!config.enabled || config.files.length === 0) return null
13
+
14
+ const expanded = expandHome(filePath)
15
+ for (const rule of config.files) {
16
+ if (matchPath(expanded, rule.path)) {
17
+ return rule
18
+ }
19
+ }
20
+ return null
21
+ }
22
+
23
+ // ── Internal helpers ──
24
+
25
+ /**
26
+ * Expand ~ to the user's home directory.
27
+ */
28
+ export function expandHome(p: string): string {
29
+ if (p.startsWith('~')) {
30
+ return homedir() + p.slice(1)
31
+ }
32
+ return p
33
+ }
34
+
35
+ /**
36
+ * Match a file path against a glob-like pattern.
37
+ * Supports:
38
+ * - ** globstar (matches any number of path segments)
39
+ * - * wildcard within a single path segment
40
+ * - ? single char
41
+ * - ~ home directory expansion
42
+ * - basename-only matching (e.g. ".env*" matches any .env file)
43
+ */
44
+ export function matchPath(filePath: string, pattern: string): boolean {
45
+ const expandedPattern = expandHome(pattern)
46
+ const normalizedFile = filePath.replace(/\\/g, '/')
47
+ const normalizedPattern = expandedPattern.replace(/\\/g, '/')
48
+
49
+ if (normalizedFile === normalizedPattern) return true
50
+
51
+ // File ends with pattern (for relative basename matching like ".env*")
52
+ if (normalizedFile.endsWith('/' + normalizedPattern)) return true
53
+
54
+ const regex = globToRegex(normalizedPattern)
55
+ if (regex.test(normalizedFile)) return true
56
+
57
+ // Basename-only matching: patterns without path separators match
58
+ // against the last component of the file path (e.g. ".env*" matches any .env file)
59
+ if (!normalizedPattern.includes('/')) {
60
+ const basename = normalizedFile.split('/').pop() || normalizedFile
61
+ if (regex.test(basename)) return true
62
+ }
63
+
64
+ return false
65
+ }
66
+
67
+ /**
68
+ * Convert a glob pattern to a RegExp.
69
+ * - ** → .* (matches any path segments)
70
+ * - * → [^/]* (matches within a single segment)
71
+ * - ? → . (single char)
72
+ * - All other special regex chars are escaped.
73
+ */
74
+ export function globToRegex(pattern: string): RegExp {
75
+ let regexStr = ''
76
+ let i = 0
77
+ while (i < pattern.length) {
78
+ const ch = pattern[i]!
79
+ if (ch === '*' && pattern[i + 1] === '*') {
80
+ // ** — match zero or more path segments
81
+ regexStr += '.*'
82
+ i += 2
83
+ // Skip trailing slash after **
84
+ if (pattern[i] === '/') i++
85
+ continue
86
+ }
87
+ if (ch === '*') {
88
+ regexStr += '[^/]*'
89
+ i++
90
+ continue
91
+ }
92
+ if (ch === '?') {
93
+ regexStr += '.'
94
+ i++
95
+ continue
96
+ }
97
+ // Escape regex special chars
98
+ if ('\\^$.|+()[]{}'.includes(ch)) {
99
+ regexStr += '\\' + ch
100
+ } else {
101
+ regexStr += ch
102
+ }
103
+ i++
104
+ }
105
+ return new RegExp('^' + regexStr + '$')
106
+ }
@@ -0,0 +1,27 @@
1
+ import { CREDENTIAL_SENTINEL } from './types'
2
+ import type { CredentialMaskingConfig } from '../../shared/index.ts'
3
+
4
+ /**
5
+ * Scrub credential patterns from stdout/stderr output.
6
+ * Uses the configured output_scrubbing patterns to detect and replace
7
+ * credentials that may have leaked into command output.
8
+ */
9
+ export function maskOutput(output: string, config: CredentialMaskingConfig): string {
10
+ if (!config.enabled || !config.output_scrubbing.enabled) return output
11
+
12
+ let masked = output
13
+ for (const pattern of config.output_scrubbing.patterns) {
14
+ try {
15
+ // Strip (?i) inline flags — JS uses the 'i' flag instead
16
+ const clean = pattern.replace(/^\(\?i\)/, '')
17
+ const regex = new RegExp(clean, 'gim')
18
+ masked = masked.replace(regex, (match) => {
19
+ // Replace everything after the separator (= or :)
20
+ return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
21
+ })
22
+ } catch {
23
+ // Invalid regex — skip
24
+ }
25
+ }
26
+ return masked
27
+ }
@@ -0,0 +1,67 @@
1
+ import type { CredentialFileRule } from '../../shared/index.ts'
2
+ import type { MaskingStrategy } from './types'
3
+ import { CREDENTIAL_SENTINEL } from './types'
4
+
5
+ /**
6
+ * MaskingPipeline chains multiple MaskingStrategy instances.
7
+ * When maskContent is called, the first strategy whose canHandle()
8
+ * returns true performs the masking. If no strategy matches, falls
9
+ * back to legacy full/extract behavior.
10
+ */
11
+ export class MaskingPipeline {
12
+ private strategies: MaskingStrategy[] = []
13
+
14
+ /** Register a strategy. Later registrations take priority (checked first). */
15
+ register(strategy: MaskingStrategy): void {
16
+ this.strategies.unshift(strategy)
17
+ }
18
+
19
+ /** Apply masking to content according to a rule. */
20
+ maskContent(content: string, rule: CredentialFileRule): string {
21
+ for (const strategy of this.strategies) {
22
+ if (strategy.canHandle(rule)) {
23
+ return strategy.mask(content, rule)
24
+ }
25
+ }
26
+
27
+ // Fallback to legacy full/extract behavior
28
+ if ('mode' in rule && rule.mode === 'full') {
29
+ return CREDENTIAL_SENTINEL
30
+ }
31
+
32
+ // Legacy extract mode
33
+ if ('mode' in rule && 'extract' in rule && Array.isArray(rule.extract)) {
34
+ let masked = content
35
+ for (const extractRule of rule.extract) {
36
+ const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
37
+ try {
38
+ const regex = new RegExp(extractRule.pattern, 'gm')
39
+ masked = masked.replace(regex, replacement)
40
+ } catch {
41
+ // Invalid regex — skip this rule silently
42
+ }
43
+ }
44
+ return masked
45
+ }
46
+
47
+ return content
48
+ }
49
+ }
50
+
51
+ /** Singleton pipeline instance. */
52
+ let _pipeline: MaskingPipeline | null = null
53
+
54
+ export function getPipeline(): MaskingPipeline {
55
+ if (!_pipeline) {
56
+ _pipeline = new MaskingPipeline()
57
+ }
58
+ return _pipeline
59
+ }
60
+
61
+ /**
62
+ * Convenience function matching the original maskContent signature.
63
+ * Uses the singleton pipeline.
64
+ */
65
+ export function maskContent(content: string, rule: CredentialFileRule): string {
66
+ return getPipeline().maskContent(content, rule)
67
+ }
@@ -0,0 +1,50 @@
1
+ import type { MaskingStrategy } from '../types'
2
+ import type { CredentialFileRule, AwsMaskingRule } from '../../../shared/index.ts'
3
+ import { CREDENTIAL_SENTINEL } from '../types'
4
+
5
+ /**
6
+ * AWS credential masking strategy.
7
+ *
8
+ * Phase 1: Detects AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY pairs
9
+ * and masks the secret key while preserving partial access key for context.
10
+ * SigV4 re-signing is deferred to Phase 2 (requires TLS termination).
11
+ */
12
+ export class AwsMaskingStrategy implements MaskingStrategy {
13
+ readonly name = 'aws'
14
+
15
+ canHandle(rule: CredentialFileRule): rule is AwsMaskingRule {
16
+ return 'type' in rule && rule.type === 'aws'
17
+ }
18
+
19
+ mask(content: string, rule: AwsMaskingRule): string {
20
+ if (!rule.awsPairs) return content
21
+
22
+ let masked = content
23
+
24
+ // Mask access key values — keep prefix for model context
25
+ masked = masked.replace(/((?:AKIA|ASIA)[A-Z0-9]{12,})/g, (_full, key: string) => {
26
+ const prefix = key.slice(0, 8) // "AKIAXXXX"
27
+ return `${prefix}${CREDENTIAL_SENTINEL}`
28
+ })
29
+
30
+ // Mask secret access key values — fully mask (no context needed)
31
+ masked = masked.replace(
32
+ /(?:AWS_SECRET_ACCESS_KEY|aws_secret_access_key)\s*[:=]\s*([A-Za-z0-9/+=]{40,})/gi,
33
+ (match) => {
34
+ const parts = match.split(/[:=]\s*/)
35
+ return `${parts[0]}=${CREDENTIAL_SENTINEL}`
36
+ },
37
+ )
38
+
39
+ // Also mask inline secret key patterns (bare base64-like strings near key markers)
40
+ masked = masked.replace(
41
+ /(?:aws_secret_access_key|secret_access_key|SECRET_ACCESS_KEY)\s*[:=]\s*([A-Za-z0-9/+=]{40,})/gi,
42
+ (match) => {
43
+ const parts = match.split(/[:=]\s*/)
44
+ return `${parts[0]}=${CREDENTIAL_SENTINEL}`
45
+ },
46
+ )
47
+
48
+ return masked
49
+ }
50
+ }
@@ -0,0 +1,106 @@
1
+ import type { MaskingStrategy } from '../types'
2
+ import type { CredentialFileRule, CredentialExtractRule } from '../../../shared/index.ts'
3
+ import { CREDENTIAL_SENTINEL } from '../types'
4
+
5
+ export class ExtractMaskingStrategy implements MaskingStrategy {
6
+ readonly name = 'extract'
7
+
8
+ canHandle(rule: CredentialFileRule): rule is CredentialExtractRule {
9
+ return 'mode' in rule && rule.mode === 'extract' && !('type' in rule)
10
+ }
11
+
12
+ mask(content: string, rule: CredentialExtractRule): string {
13
+ let masked = content
14
+ let anyMatch = false
15
+
16
+ for (const extractRule of rule.extract) {
17
+ const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
18
+
19
+ if (extractRule.field) {
20
+ // Structured extraction: extract field by key before applying pattern
21
+ masked = this.maskField(masked, extractRule.field, extractRule.pattern, replacement)
22
+ anyMatch = true // field extraction always applies
23
+ } else {
24
+ // Traditional regex extraction
25
+ try {
26
+ const regex = new RegExp(extractRule.pattern, 'gm')
27
+ if (regex.test(masked)) {
28
+ anyMatch = true
29
+ // Re-create regex since test() advances lastIndex
30
+ const re = new RegExp(extractRule.pattern, 'gm')
31
+ masked = masked.replace(re, replacement)
32
+ }
33
+ } catch {
34
+ // Invalid regex — skip
35
+ }
36
+ }
37
+ }
38
+
39
+ // Handle onExtractNoMatch
40
+ const onNoMatch = rule.onExtractNoMatch || 'mask'
41
+ if (!anyMatch && onNoMatch === 'mask') {
42
+ return CREDENTIAL_SENTINEL
43
+ }
44
+
45
+ return masked
46
+ }
47
+
48
+ /**
49
+ * Extract a JSON field by key and apply pattern masking to its value.
50
+ * Supports nested keys with dot notation (e.g. "auth.api_key").
51
+ */
52
+ private maskField(
53
+ content: string,
54
+ fieldPath: string,
55
+ pattern: string,
56
+ replacement: string,
57
+ ): string {
58
+ try {
59
+ // Try parsing as JSON
60
+ const parsed = JSON.parse(content)
61
+ this.setNestedValue(parsed, fieldPath, (value) => {
62
+ if (typeof value === 'string') {
63
+ try {
64
+ const regex = new RegExp(pattern)
65
+ return value.replace(regex, replacement)
66
+ } catch {
67
+ return replacement
68
+ }
69
+ }
70
+ return replacement
71
+ })
72
+ return JSON.stringify(parsed, null, 2)
73
+ } catch {
74
+ // Not valid JSON — fall back to regex on raw content
75
+ try {
76
+ const regex = new RegExp(pattern, 'gm')
77
+ return content.replace(regex, replacement)
78
+ } catch {
79
+ return content
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Set a nested value in an object using dot-notation path.
86
+ */
87
+ private setNestedValue(
88
+ obj: Record<string, unknown>,
89
+ path: string,
90
+ transform: (value: unknown) => unknown,
91
+ ): void {
92
+ const keys = path.split('.')
93
+ let current: Record<string, unknown> = obj
94
+
95
+ for (let i = 0; i < keys.length - 1; i++) {
96
+ const key = keys[i]!
97
+ if (!current[key] || typeof current[key] !== 'object') return
98
+ current = current[key] as Record<string, unknown>
99
+ }
100
+
101
+ const lastKey = keys[keys.length - 1]!
102
+ if (lastKey in current) {
103
+ current[lastKey] = transform(current[lastKey])
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,15 @@
1
+ import type { MaskingStrategy } from '../types'
2
+ import type { CredentialFileRule, CredentialFullMaskRule } from '../../../shared/index.ts'
3
+ import { CREDENTIAL_SENTINEL } from '../types'
4
+
5
+ export class FullMaskingStrategy implements MaskingStrategy {
6
+ readonly name = 'full'
7
+
8
+ canHandle(rule: CredentialFileRule): rule is CredentialFullMaskRule {
9
+ return 'mode' in rule && rule.mode === 'full' && !('type' in rule)
10
+ }
11
+
12
+ mask(_content: string, _rule: CredentialFullMaskRule): string {
13
+ return CREDENTIAL_SENTINEL
14
+ }
15
+ }
@@ -0,0 +1,75 @@
1
+ import type { MaskingStrategy } from '../types'
2
+ import type { CredentialFileRule, JwtMaskingRule } from '../../../shared/index.ts'
3
+
4
+ export class JwtMaskingStrategy implements MaskingStrategy {
5
+ readonly name = 'jwt'
6
+
7
+ canHandle(rule: CredentialFileRule): rule is JwtMaskingRule {
8
+ return 'type' in rule && rule.type === 'jwt'
9
+ }
10
+
11
+ mask(content: string, rule: JwtMaskingRule): string {
12
+ return content
13
+ .split('\n')
14
+ .map((line) => this.maskLine(line, rule))
15
+ .join('\n')
16
+ }
17
+
18
+ private maskLine(line: string, rule: JwtMaskingRule): string {
19
+ const trimmed = line.trim()
20
+
21
+ // Check if this looks like a JWT (three base64url segments separated by dots)
22
+ const segments = trimmed.split('.')
23
+ if (segments.length !== 3) return line
24
+
25
+ // Verify each segment is base64url
26
+ const b64urlRegex = /^[A-Za-z0-9_-]+$/
27
+ if (!segments.every((s) => b64urlRegex.test(s))) return line
28
+
29
+ try {
30
+ const header = segments[0]!
31
+ const payload = segments[1]!
32
+ const signature = segments[2]!
33
+
34
+ // Decode payload
35
+ const decodedPayload = this.decodeBase64Url(payload)
36
+ let payloadObj: Record<string, unknown>
37
+ try {
38
+ payloadObj = JSON.parse(decodedPayload)
39
+ } catch {
40
+ return line // Not valid JSON — not a JWT
41
+ }
42
+
43
+ // Mask specified claims
44
+ let modified = false
45
+ for (const claim of rule.maskClaims) {
46
+ if (claim in payloadObj) {
47
+ payloadObj[claim] = '[MASKED]'
48
+ modified = true
49
+ }
50
+ }
51
+
52
+ if (!modified) return line
53
+
54
+ // Re-encode payload
55
+ const newPayload = this.encodeBase64Url(JSON.stringify(payloadObj))
56
+ return `${header}.${newPayload}.${signature}`
57
+ } catch {
58
+ return line // Decode failure — return unchanged
59
+ }
60
+ }
61
+
62
+ private decodeBase64Url(str: string): string {
63
+ // Convert base64url to base64
64
+ const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
65
+ // Pad
66
+ const padded = base64 + '==='.slice(0, (4 - (base64.length % 4)) % 4)
67
+ // Decode
68
+ return Buffer.from(padded, 'base64').toString('utf-8')
69
+ }
70
+
71
+ private encodeBase64Url(str: string): string {
72
+ const base64 = Buffer.from(str, 'utf-8').toString('base64')
73
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
74
+ }
75
+ }
@@ -0,0 +1,16 @@
1
+ import type { CredentialFileRule } from '../../shared/index.ts'
2
+
3
+ /** Sentinel value used to replace masked credentials. */
4
+ export const CREDENTIAL_SENTINEL = '__MIPHAM_CREDENTIAL_MASKED__'
5
+
6
+ /** Strategy interface for credential masking. Each strategy handles one rule type. */
7
+ export interface MaskingStrategy {
8
+ /** Unique name for this strategy. */
9
+ readonly name: string
10
+
11
+ /** Return true if this strategy handles the given rule. */
12
+ canHandle(rule: CredentialFileRule): boolean
13
+
14
+ /** Apply masking to content according to the rule. Returns masked content. */
15
+ mask(content: string, rule: CredentialFileRule): string
16
+ }
@@ -1,117 +1,23 @@
1
- import { homedir } from 'node:os'
2
- import type { CredentialMaskingConfig, CredentialFileRule } from '../shared/index.ts'
3
-
4
- /** Sentinel value used to replace masked credentials. */
5
- export const CREDENTIAL_SENTINEL = '__MIPHAM_CREDENTIAL_MASKED__'
6
-
7
1
  /**
8
- * Check if a file path matches any credential file rule.
9
- * Returns the matching rule, or null if no match.
2
+ * Backward-compatible re-exports from the modular credential-masker/ pipeline.
3
+ *
4
+ * This file preserves the original API surface while delegating to the
5
+ * modular strategy-based implementation under credential-masker/.
10
6
  */
11
- export function matchCredentialFile(
12
- filePath: string,
13
- config: CredentialMaskingConfig,
14
- ): CredentialFileRule | null {
15
- if (!config.enabled || config.files.length === 0) return null
16
-
17
- const expanded = expandHome(filePath)
18
- for (const rule of config.files) {
19
- if (matchPath(expanded, rule.path)) {
20
- return rule
21
- }
22
- }
23
- return null
24
- }
25
7
 
26
- /**
27
- * Apply credential masking to file content.
28
- * - 'full' mode: return sentinel value only
29
- * - 'extract' mode: replace regex-matched tokens with sentinel
30
- */
31
- export function maskContent(content: string, rule: CredentialFileRule): string {
32
- if (rule.mode === 'full') {
33
- return CREDENTIAL_SENTINEL
34
- }
35
-
36
- // extract mode
37
- let masked = content
38
- for (const extractRule of rule.extract) {
39
- const replacement = extractRule.replacement || CREDENTIAL_SENTINEL
40
- try {
41
- const regex = new RegExp(extractRule.pattern, 'gm')
42
- masked = masked.replace(regex, replacement)
43
- } catch {
44
- // Invalid regex — skip this rule silently
45
- }
46
- }
47
- return masked
48
- }
8
+ // Re-export sentinel constant
9
+ export { CREDENTIAL_SENTINEL } from './credential-masker/types'
49
10
 
50
- /**
51
- * Scrub credential patterns from stdout/stderr output.
52
- * Uses the configured output_scrubbing patterns to detect and replace
53
- * credentials that may have leaked into command output.
54
- */
55
- export function maskOutput(output: string, config: CredentialMaskingConfig): string {
56
- if (!config.enabled || !config.output_scrubbing.enabled) return output
57
-
58
- let masked = output
59
- for (const pattern of config.output_scrubbing.patterns) {
60
- try {
61
- // Strip (?i) inline flags — JS uses the 'i' flag instead
62
- const clean = pattern.replace(/^\(\?i\)/, '')
63
- const regex = new RegExp(clean, 'gim')
64
- masked = masked.replace(regex, (match) => {
65
- // Replace everything after the separator (= or :)
66
- return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
67
- })
68
- } catch {
69
- // Invalid regex — skip
70
- }
71
- }
72
- return masked
73
- }
74
-
75
- /**
76
- * Filter sensitive environment variables before spawning a subprocess.
77
- * Returns a sanitized copy of process.env with matching vars removed.
78
- */
79
- export function filterEnv(
80
- env: Record<string, string | undefined>,
81
- config: CredentialMaskingConfig,
82
- ): Record<string, string | undefined> {
83
- if (!config.enabled || !config.env_filter.enabled) return { ...env }
84
-
85
- const filtered: Record<string, string | undefined> = {}
86
- for (const [key, value] of Object.entries(env)) {
87
- let blocked = false
88
- for (const pattern of config.env_filter.patterns) {
89
- try {
90
- // Strip (?i) inline flags — JS uses the 'i' flag instead
91
- const clean = pattern.replace(/^\(\?i\)/, '')
92
- const regex = new RegExp(clean, 'i')
93
- if (regex.test(key)) {
94
- blocked = true
95
- break
96
- }
97
- } catch {
98
- // Invalid regex — skip
99
- }
100
- }
101
- if (blocked) {
102
- // Replace with sentinel so tools can detect they're filtered
103
- filtered[key] = CREDENTIAL_SENTINEL
104
- } else {
105
- filtered[key] = value
106
- }
107
- }
11
+ // Re-export core functions
12
+ export { matchCredentialFile } from './credential-masker/matcher'
13
+ export { maskContent } from './credential-masker/pipeline'
14
+ export { maskOutput } from './credential-masker/output-scrub'
15
+ export { filterEnv } from './credential-masker/env-filter'
108
16
 
109
- return filtered
110
- }
17
+ import { CREDENTIAL_SENTINEL } from './credential-masker/types'
111
18
 
112
19
  /**
113
20
  * Get the file display name for a sentinel value (for model visibility).
114
- * The model sees the sentinel, doesn't know the original file path.
115
21
  */
116
22
  export function getSentinelDisplay(hint?: string): string {
117
23
  if (hint) {
@@ -119,84 +25,3 @@ export function getSentinelDisplay(hint?: string): string {
119
25
  }
120
26
  return CREDENTIAL_SENTINEL
121
27
  }
122
-
123
- // ── Internal helpers ──
124
-
125
- /**
126
- * Expand ~ to the user's home directory.
127
- */
128
- function expandHome(p: string): string {
129
- if (p.startsWith('~')) {
130
- return homedir() + p.slice(1)
131
- }
132
- return p
133
- }
134
-
135
- /**
136
- * Match a file path against a glob-like pattern.
137
- * Supports:
138
- * - ** globstar (matches any number of path segments)
139
- * - * wildcard within a single path segment
140
- * - ~ home directory expansion
141
- * - basename-only matching (e.g. ".env*" matches any .env file)
142
- */
143
- function matchPath(filePath: string, pattern: string): boolean {
144
- const expandedPattern = expandHome(pattern)
145
- const normalizedFile = filePath.replace(/\\/g, '/')
146
- const normalizedPattern = expandedPattern.replace(/\\/g, '/')
147
-
148
- // Exact match
149
- if (normalizedFile === normalizedPattern) {
150
- return true
151
- }
152
-
153
- // File ends with pattern (for relative basename matching like ".env*")
154
- if (normalizedFile.endsWith('/' + normalizedPattern)) {
155
- return true
156
- }
157
-
158
- // Convert glob pattern to regex
159
- const regex = globToRegex(normalizedPattern)
160
- return regex.test(normalizedFile)
161
- }
162
-
163
- /**
164
- * Convert a glob pattern to a RegExp.
165
- * - ** → .* (matches any path segments)
166
- * - * → [^/]* (matches within a single segment)
167
- * - ? → . (single char)
168
- * - All other special regex chars are escaped.
169
- */
170
- function globToRegex(pattern: string): RegExp {
171
- let regexStr = ''
172
- let i = 0
173
- while (i < pattern.length) {
174
- const ch = pattern[i]!
175
- if (ch === '*' && pattern[i + 1] === '*') {
176
- // ** — match zero or more path segments
177
- regexStr += '.*'
178
- i += 2
179
- // Skip trailing slash after **
180
- if (pattern[i] === '/') i++
181
- continue
182
- }
183
- if (ch === '*') {
184
- regexStr += '[^/]*'
185
- i++
186
- continue
187
- }
188
- if (ch === '?') {
189
- regexStr += '.'
190
- i++
191
- continue
192
- }
193
- // Escape regex special chars
194
- if ('\\^$.|+()[]{}'.includes(ch)) {
195
- regexStr += '\\' + ch
196
- } else {
197
- regexStr += ch
198
- }
199
- i++
200
- }
201
- return new RegExp('^' + regexStr + '$')
202
- }