dsh-autotier 0.1.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/AGENTS.md +93 -0
- package/CHANGELOG.md +85 -0
- package/LICENSE +201 -0
- package/README.es.md +247 -0
- package/README.hi.md +241 -0
- package/README.md +245 -0
- package/README.pt.md +246 -0
- package/README.zh.md +221 -0
- package/SECURITY.md +55 -0
- package/THIRD_PARTY_NOTICES.md +63 -0
- package/cordis.patch.yml +125 -0
- package/docs/preset-row.md +61 -0
- package/docs/supporting-lanes.md +45 -0
- package/lib/index.js +2848 -0
- package/lib/types/command.d.ts +17 -0
- package/lib/types/command.d.ts.map +1 -0
- package/lib/types/config.d.ts +94 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/guard-rules.d.ts +97 -0
- package/lib/types/guard-rules.d.ts.map +1 -0
- package/lib/types/guard.d.ts +70 -0
- package/lib/types/guard.d.ts.map +1 -0
- package/lib/types/index.d.ts +60 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/intent.d.ts +179 -0
- package/lib/types/intent.d.ts.map +1 -0
- package/lib/types/judge.d.ts +50 -0
- package/lib/types/judge.d.ts.map +1 -0
- package/lib/types/policy.d.ts +109 -0
- package/lib/types/policy.d.ts.map +1 -0
- package/lib/types/routing.d.ts +135 -0
- package/lib/types/routing.d.ts.map +1 -0
- package/lib/types/schema.d.ts +134 -0
- package/lib/types/schema.d.ts.map +1 -0
- package/lib/types/service.d.ts +67 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/state.d.ts +46 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/tiers.d.ts +103 -0
- package/lib/types/tiers.d.ts.map +1 -0
- package/lib/types/tools.d.ts +26 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/types.d.ts +96 -0
- package/lib/types/types.d.ts.map +1 -0
- package/package.json +179 -0
- package/src/command.ts +73 -0
- package/src/config.ts +358 -0
- package/src/guard-rules.ts +303 -0
- package/src/guard.ts +285 -0
- package/src/index.ts +149 -0
- package/src/intent.ts +484 -0
- package/src/judge.ts +150 -0
- package/src/policy.ts +246 -0
- package/src/routing.ts +575 -0
- package/src/schema.ts +295 -0
- package/src/service.ts +131 -0
- package/src/state.ts +134 -0
- package/src/tiers.ts +212 -0
- package/src/tools.ts +128 -0
- package/src/types.ts +120 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The explicit-resolve judge for dsh-autotier: it re-checks every default,
|
|
3
|
+
* bound and cross-field requirement field by field, so programmatic
|
|
4
|
+
* construction that bypasses Schemastery normalization still fails loud. The
|
|
5
|
+
* schema itself lives in `schema.ts`.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-autotier/config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
COST_MODES,
|
|
12
|
+
EFFORT_IDS,
|
|
13
|
+
ROUTING_MODES,
|
|
14
|
+
SCENARIOS,
|
|
15
|
+
type CostMode,
|
|
16
|
+
type EffortId,
|
|
17
|
+
type RoutingMode,
|
|
18
|
+
} from './types.ts'
|
|
19
|
+
import { DEFAULT_CHEAP, DEFAULT_STRONG, DEFAULT_VISION } from './schema.ts'
|
|
20
|
+
import type {
|
|
21
|
+
Config,
|
|
22
|
+
EscalationConfig,
|
|
23
|
+
GuardConfig,
|
|
24
|
+
IntentConfig,
|
|
25
|
+
JudgeConfig,
|
|
26
|
+
ScenarioToggles,
|
|
27
|
+
TierConfig,
|
|
28
|
+
VisionConfig,
|
|
29
|
+
} from './schema.ts'
|
|
30
|
+
|
|
31
|
+
export { Config } from './schema.ts'
|
|
32
|
+
export type {
|
|
33
|
+
EscalationConfig,
|
|
34
|
+
FallbackEntry,
|
|
35
|
+
GuardConfig,
|
|
36
|
+
IntentConfig,
|
|
37
|
+
IntentRule,
|
|
38
|
+
JudgeConfig,
|
|
39
|
+
ScenarioToggles,
|
|
40
|
+
TierConfig,
|
|
41
|
+
VisionConfig,
|
|
42
|
+
} from './schema.ts'
|
|
43
|
+
|
|
44
|
+
/** One resolved fallback landing. */
|
|
45
|
+
export interface ResolvedFallbackEntry {
|
|
46
|
+
provider: string
|
|
47
|
+
model: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One resolved tier landing. Runtime-frozen by {@link resolveConfig}. */
|
|
51
|
+
export interface ResolvedTierConfig {
|
|
52
|
+
provider: string
|
|
53
|
+
model: string
|
|
54
|
+
effort: EffortId
|
|
55
|
+
followSession: boolean
|
|
56
|
+
fallback: ResolvedFallbackEntry[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One fully-resolved declarative rule. */
|
|
60
|
+
export interface ResolvedRule {
|
|
61
|
+
id: string
|
|
62
|
+
when: { patterns: string[]; tools: string[]; cwd: string }
|
|
63
|
+
tier: 'cheap' | 'strong'
|
|
64
|
+
priority: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Fully-resolved configuration: every field present, runtime-frozen. */
|
|
68
|
+
export interface ResolvedConfig {
|
|
69
|
+
tiers: {
|
|
70
|
+
strong: ResolvedTierConfig
|
|
71
|
+
cheap: ResolvedTierConfig
|
|
72
|
+
vision: Required<VisionConfig>
|
|
73
|
+
}
|
|
74
|
+
intent: {
|
|
75
|
+
ruleThreshold: number
|
|
76
|
+
attemptBand: { enabled: boolean; tauLow: number }
|
|
77
|
+
hysteresis: { toStrong: number; toCheap: number }
|
|
78
|
+
rules: ResolvedRule[]
|
|
79
|
+
judge: Required<JudgeConfig>
|
|
80
|
+
scenarios: Required<ScenarioToggles>
|
|
81
|
+
costMode: CostMode
|
|
82
|
+
}
|
|
83
|
+
guard: {
|
|
84
|
+
enabled: boolean
|
|
85
|
+
tiers: ('cheap')[]
|
|
86
|
+
whitelist: string[]
|
|
87
|
+
protectedPaths: string[]
|
|
88
|
+
interopDefend: 'auto' | 'none'
|
|
89
|
+
}
|
|
90
|
+
escalation: {
|
|
91
|
+
threshold: number
|
|
92
|
+
windowMs: number
|
|
93
|
+
ttlMs: number
|
|
94
|
+
fallbackTtlMs: number
|
|
95
|
+
signature: boolean
|
|
96
|
+
}
|
|
97
|
+
routingMode: RoutingMode
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Throw the standard fail-loud config error for one invalid field. */
|
|
101
|
+
function invalid(field: string, detail: string): never {
|
|
102
|
+
throw new Error(`dsh-autotier: config.${field} ${detail}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Read a required non-empty string field, failing loud when absent or blank. */
|
|
106
|
+
function text(field: string, value: string | undefined, fallback: string): string {
|
|
107
|
+
const resolved = value ?? fallback
|
|
108
|
+
if (typeof resolved !== 'string' || resolved.trim().length === 0) invalid(field, 'must be a non-empty string')
|
|
109
|
+
return resolved
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Read a bounded finite number. */
|
|
113
|
+
function number(field: string, value: number | undefined, fallback: number, min: number, max: number): number {
|
|
114
|
+
const resolved = value ?? fallback
|
|
115
|
+
if (!Number.isFinite(resolved) || resolved < min || resolved > max) {
|
|
116
|
+
invalid(field, `must be a finite number in [${String(min)}, ${String(max)}]`)
|
|
117
|
+
}
|
|
118
|
+
return resolved
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Read an integer in a closed range. */
|
|
122
|
+
function integer(field: string, value: number | undefined, fallback: number, min: number, max: number): number {
|
|
123
|
+
const resolved = value ?? fallback
|
|
124
|
+
if (!Number.isInteger(resolved) || resolved < min || resolved > max) {
|
|
125
|
+
invalid(field, `must be an integer in [${String(min)}, ${String(max)}]`)
|
|
126
|
+
}
|
|
127
|
+
return resolved
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read a boolean switch. */
|
|
131
|
+
function boolean(field: string, value: boolean | undefined, fallback: boolean): boolean {
|
|
132
|
+
const resolved = value ?? fallback
|
|
133
|
+
if (typeof resolved !== 'boolean') invalid(field, 'must be a boolean')
|
|
134
|
+
return resolved
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read one member of a closed string set. */
|
|
138
|
+
function member<T extends string>(field: string, value: string | undefined, fallback: T, allowed: readonly T[]): T {
|
|
139
|
+
const resolved = value ?? fallback
|
|
140
|
+
if (!(allowed as readonly string[]).includes(resolved)) {
|
|
141
|
+
invalid(field, `must be one of ${allowed.join(', ')}`)
|
|
142
|
+
}
|
|
143
|
+
return resolved as T
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Resolve one tier, judging its landing, effort vocabulary and fallback chain. */
|
|
147
|
+
function resolveTier(
|
|
148
|
+
tier: 'strong' | 'cheap',
|
|
149
|
+
raw: TierConfig | undefined,
|
|
150
|
+
fallback: { provider: string; model: string; effort: EffortId; followSession: boolean },
|
|
151
|
+
): ResolvedTierConfig {
|
|
152
|
+
const provider = text(`tiers.${tier}.provider`, raw?.provider, fallback.provider)
|
|
153
|
+
const model = text(`tiers.${tier}.model`, raw?.model, fallback.model)
|
|
154
|
+
const effort = member(`tiers.${tier}.effort`, raw?.effort, fallback.effort, EFFORT_IDS)
|
|
155
|
+
const followSession = boolean(`tiers.${tier}.followSession`, raw?.followSession, fallback.followSession)
|
|
156
|
+
const chain: ResolvedFallbackEntry[] = []
|
|
157
|
+
const seen = new Set<string>([`${provider}/${model}`])
|
|
158
|
+
for (const [index, entry] of (raw?.fallback ?? []).entries()) {
|
|
159
|
+
const entryProvider = text(`tiers.${tier}.fallback[${String(index)}].provider`, entry.provider, '')
|
|
160
|
+
const entryModel = text(`tiers.${tier}.fallback[${String(index)}].model`, entry.model, '')
|
|
161
|
+
const key = `${entryProvider}/${entryModel}`
|
|
162
|
+
if (seen.has(key)) {
|
|
163
|
+
invalid(`tiers.${tier}.fallback[${String(index)}]`, `duplicates the tier landing or an earlier fallback (${key})`)
|
|
164
|
+
}
|
|
165
|
+
seen.add(key)
|
|
166
|
+
chain.push({ provider: entryProvider, model: entryModel })
|
|
167
|
+
}
|
|
168
|
+
return { provider, model, effort, followSession, fallback: chain }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Resolve the intent section. */
|
|
172
|
+
function resolveIntent(raw: IntentConfig | undefined): ResolvedConfig['intent'] {
|
|
173
|
+
const intent = raw ?? {}
|
|
174
|
+
const ruleThreshold = number('intent.ruleThreshold', intent.ruleThreshold, 0.7, 0.000001, 1)
|
|
175
|
+
const tauLow = number('intent.attemptBand.tauLow', intent.attemptBand?.tauLow, 0.45, 0, 1)
|
|
176
|
+
if (tauLow >= ruleThreshold) {
|
|
177
|
+
invalid('intent.attemptBand.tauLow', `must stay below intent.ruleThreshold (${String(tauLow)} >= ${String(ruleThreshold)})`)
|
|
178
|
+
}
|
|
179
|
+
const toStrong = number('intent.hysteresis.toStrong', intent.hysteresis?.toStrong, 0.8, 0, 1)
|
|
180
|
+
const toCheap = number('intent.hysteresis.toCheap', intent.hysteresis?.toCheap, 0.6, 0, 1)
|
|
181
|
+
if (toCheap >= toStrong) {
|
|
182
|
+
invalid('intent.hysteresis', `toCheap (${String(toCheap)}) must stay below toStrong (${String(toStrong)})`)
|
|
183
|
+
}
|
|
184
|
+
const rules: ResolvedRule[] = []
|
|
185
|
+
const ruleIds = new Set<string>()
|
|
186
|
+
for (const [index, rule] of (intent.rules ?? []).entries()) {
|
|
187
|
+
const id = text(`intent.rules[${String(index)}].id`, rule.id, '')
|
|
188
|
+
if (ruleIds.has(id)) invalid(`intent.rules[${String(index)}].id`, `duplicates rule id ${JSON.stringify(id)}`)
|
|
189
|
+
ruleIds.add(id)
|
|
190
|
+
const priority = rule.priority ?? 0
|
|
191
|
+
if (!Number.isFinite(priority)) invalid(`intent.rules[${String(index)}].priority`, 'must be a finite number')
|
|
192
|
+
const patterns = [...(rule.when?.patterns ?? [])]
|
|
193
|
+
const tools = [...(rule.when?.tools ?? [])]
|
|
194
|
+
if (patterns.length === 0 && tools.length === 0) {
|
|
195
|
+
invalid(`intent.rules[${String(index)}].when`, 'must declare at least one pattern or tool')
|
|
196
|
+
}
|
|
197
|
+
for (const [patternIndex, pattern] of patterns.entries()) {
|
|
198
|
+
if (typeof pattern !== 'string' || pattern.length === 0) {
|
|
199
|
+
invalid(`intent.rules[${String(index)}].when.patterns[${String(patternIndex)}]`, 'must be a non-empty regular expression')
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
// Compile with the same flags `compileRules` uses, so a pattern the
|
|
203
|
+
// judge accepts can never throw later inside a settings watcher.
|
|
204
|
+
new RegExp(pattern, 'u')
|
|
205
|
+
} catch (error) {
|
|
206
|
+
invalid(
|
|
207
|
+
`intent.rules[${String(index)}].when.patterns[${String(patternIndex)}]`,
|
|
208
|
+
`is not a valid regular expression (${String(error)})`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const [toolIndex, tool] of tools.entries()) {
|
|
213
|
+
if (typeof tool !== 'string' || tool.trim().length === 0) {
|
|
214
|
+
invalid(`intent.rules[${String(index)}].when.tools[${String(toolIndex)}]`, 'must be a non-empty tool name')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
rules.push({
|
|
218
|
+
id,
|
|
219
|
+
when: { patterns, tools, cwd: rule.when?.cwd ?? '' },
|
|
220
|
+
tier: member(`intent.rules[${String(index)}].tier`, rule.tier, 'strong', ['cheap', 'strong'] as const),
|
|
221
|
+
priority,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
const judge = intent.judge ?? {}
|
|
225
|
+
const judgeModel = judge.model ?? ''
|
|
226
|
+
if (typeof judgeModel !== 'string') invalid('intent.judge.model', 'must be a string (empty = auto)')
|
|
227
|
+
const scenarios = Object.fromEntries(SCENARIOS.map(scenario => [
|
|
228
|
+
scenario,
|
|
229
|
+
boolean(`intent.scenarios.${scenario}`, intent.scenarios?.[scenario], true),
|
|
230
|
+
])) as Required<ScenarioToggles>
|
|
231
|
+
return {
|
|
232
|
+
ruleThreshold,
|
|
233
|
+
attemptBand: { enabled: boolean('intent.attemptBand.enabled', intent.attemptBand?.enabled, false), tauLow },
|
|
234
|
+
hysteresis: { toStrong, toCheap },
|
|
235
|
+
rules,
|
|
236
|
+
judge: {
|
|
237
|
+
enabled: boolean('intent.judge.enabled', judge.enabled, true),
|
|
238
|
+
model: judgeModel,
|
|
239
|
+
temperature: number('intent.judge.temperature', judge.temperature, 0, 0, 2),
|
|
240
|
+
maxTokens: integer('intent.judge.maxTokens', judge.maxTokens, 16, 1, 4_096),
|
|
241
|
+
cooldownMs: number('intent.judge.cooldownMs', judge.cooldownMs, 30_000, 0, 3_600_000),
|
|
242
|
+
timeoutMs: number('intent.judge.timeoutMs', judge.timeoutMs, 2_000, 1, 120_000),
|
|
243
|
+
unavailableSkip: integer('intent.judge.unavailableSkip', judge.unavailableSkip, 2, 0, 100),
|
|
244
|
+
},
|
|
245
|
+
scenarios,
|
|
246
|
+
costMode: member('intent.costMode', intent.costMode, 'balanced', COST_MODES),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Resolve the guard section. */
|
|
251
|
+
function resolveGuard(raw: GuardConfig | undefined): ResolvedConfig['guard'] {
|
|
252
|
+
const guard = raw ?? {}
|
|
253
|
+
const whitelist = [...(guard.whitelist ?? [])]
|
|
254
|
+
for (const [index, entry] of whitelist.entries()) {
|
|
255
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
256
|
+
invalid(`guard.whitelist[${String(index)}]`, 'must be a non-empty string')
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const protectedPaths = [...(guard.protectedPaths ?? ['.dsh', 'AGENTS.md', 'package.json', '.github/workflows'])]
|
|
260
|
+
for (const [index, entry] of protectedPaths.entries()) {
|
|
261
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
262
|
+
invalid(`guard.protectedPaths[${String(index)}]`, 'must be a non-empty path or glob')
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const tiers: ('cheap')[] = guard.tiers === undefined ? ['cheap'] : guard.tiers.map((tier, index) => {
|
|
266
|
+
if (tier !== 'cheap') invalid(`guard.tiers[${String(index)}]`, 'must be "cheap"')
|
|
267
|
+
return tier
|
|
268
|
+
})
|
|
269
|
+
if (tiers.length === 0) {
|
|
270
|
+
invalid('guard.tiers', 'must list at least one tier; use guard.enabled=false to disable the guard')
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
enabled: boolean('guard.enabled', guard.enabled, true),
|
|
274
|
+
tiers,
|
|
275
|
+
whitelist,
|
|
276
|
+
protectedPaths,
|
|
277
|
+
interopDefend: member('guard.interopDefend', guard.interopDefend, 'auto', ['auto', 'none'] as const),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Resolve the escalation section. */
|
|
282
|
+
function resolveEscalation(raw: EscalationConfig | undefined): ResolvedConfig['escalation'] {
|
|
283
|
+
const escalation = raw ?? {}
|
|
284
|
+
return {
|
|
285
|
+
threshold: integer('escalation.threshold', escalation.threshold, 2, 1, 100),
|
|
286
|
+
windowMs: number('escalation.windowMs', escalation.windowMs, 60_000, 1, 86_400_000),
|
|
287
|
+
ttlMs: number('escalation.ttlMs', escalation.ttlMs, 180_000, 1, 86_400_000),
|
|
288
|
+
fallbackTtlMs: number('escalation.fallbackTtlMs', escalation.fallbackTtlMs, 300_000, 1, 86_400_000),
|
|
289
|
+
signature: boolean('escalation.signature', escalation.signature, true),
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Deep-freeze a resolved configuration tree. */
|
|
294
|
+
function deepFreeze<T>(value: T): T {
|
|
295
|
+
if (value !== null && typeof value === 'object') {
|
|
296
|
+
for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested)
|
|
297
|
+
Object.freeze(value)
|
|
298
|
+
}
|
|
299
|
+
return value
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The landing a tier actually resolves to. `followSession` tiers omit their
|
|
304
|
+
* effort, so two tiers that differ only by a configured-but-ignored effort are
|
|
305
|
+
* the same landing and must be rejected.
|
|
306
|
+
*/
|
|
307
|
+
function effectiveLanding(tier: ResolvedTierConfig): string {
|
|
308
|
+
return tier.followSession
|
|
309
|
+
? `${tier.provider}/${tier.model}@session`
|
|
310
|
+
: `${tier.provider}/${tier.model}@${tier.effort}`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Resolve raw config to the frozen runtime policy, re-judging every default,
|
|
315
|
+
* bound and cross-field requirement.
|
|
316
|
+
*
|
|
317
|
+
* @param raw - raw loader config; `undefined` for a bare row.
|
|
318
|
+
* @returns the frozen resolved config.
|
|
319
|
+
* @throws {Error} when a value is out of bounds or a cross-field requirement fails.
|
|
320
|
+
*/
|
|
321
|
+
export function resolveConfig(raw: Config | undefined): ResolvedConfig {
|
|
322
|
+
const tiers = raw?.tiers ?? {}
|
|
323
|
+
const strong = resolveTier('strong', tiers.strong, DEFAULT_STRONG)
|
|
324
|
+
const cheap = resolveTier('cheap', tiers.cheap, DEFAULT_CHEAP)
|
|
325
|
+
const strongTriple = effectiveLanding(strong)
|
|
326
|
+
const cheapTriple = effectiveLanding(cheap)
|
|
327
|
+
if (strongTriple === cheapTriple) {
|
|
328
|
+
invalid('tiers', `strong and cheap resolve to the same landing (${strongTriple}); tiering would be a no-op`)
|
|
329
|
+
}
|
|
330
|
+
const resolved: ResolvedConfig = {
|
|
331
|
+
tiers: {
|
|
332
|
+
strong,
|
|
333
|
+
cheap,
|
|
334
|
+
vision: {
|
|
335
|
+
provider: text('tiers.vision.provider', tiers.vision?.provider, DEFAULT_VISION.provider),
|
|
336
|
+
model: text('tiers.vision.model', tiers.vision?.model, DEFAULT_VISION.model),
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
intent: resolveIntent(raw?.intent),
|
|
340
|
+
guard: resolveGuard(raw?.guard),
|
|
341
|
+
escalation: resolveEscalation(raw?.escalation),
|
|
342
|
+
routingMode: member('routingMode', raw?.routingMode, 'auto', ROUTING_MODES),
|
|
343
|
+
}
|
|
344
|
+
return deepFreeze(resolved)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Judge a configuration without keeping the resolved value. This is the
|
|
349
|
+
* save-time hook the `autotier` settings namespace registers, so a user write
|
|
350
|
+
* that violates a cross-field requirement is refused at the write instead of
|
|
351
|
+
* silently disabling the plugin.
|
|
352
|
+
*
|
|
353
|
+
* @param value - the configuration to judge.
|
|
354
|
+
* @throws {Error} when the configuration is invalid.
|
|
355
|
+
*/
|
|
356
|
+
export function validateConfig(value: Config): void {
|
|
357
|
+
resolveConfig(value)
|
|
358
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-impact command and path rules for the autotier guard.
|
|
3
|
+
*
|
|
4
|
+
* A TypeScript port of the dependency-free rule logic in `lib/pure.js` of
|
|
5
|
+
* `dsh-tier-router` (v0.5.0, MIT — see `THIRD_PARTY_NOTICES.md`): the
|
|
6
|
+
* `ARG_RUNNERS`/`DIRECT_RUNNERS` command-position detection, the 16
|
|
7
|
+
* `HIGH_IMPACT_COMMAND` patterns, and the 5 `HIGH_IMPACT_PATH` patterns. The
|
|
8
|
+
* upstream matching order, case-insensitivity, anchored command position, and
|
|
9
|
+
* 80/120-character truncation are preserved exactly; the port only adds stable
|
|
10
|
+
* rule ids and the `GuardMatch` shape the guard layer consumes.
|
|
11
|
+
*
|
|
12
|
+
* INTENTIONAL DELTA OVER UPSTREAM (the plugin's own Apache-2.0 addition, not
|
|
13
|
+
* upstream code): upstream never looks inside a `sh -c "..."` payload, so
|
|
14
|
+
* `sh -c "rm -rf /"` is a documented false negative. {@link SHELL_WRAPPERS} and
|
|
15
|
+
* {@link SHELL_WRAPPER_MAX_DEPTH} add a second pass that runs only after the
|
|
16
|
+
* upstream rules return no match: it strips a leading command runner, extracts
|
|
17
|
+
* the `-c` payload (single-dash flag clusters such as `-lc`, case-insensitive
|
|
18
|
+
* wrapper names, and the optional backslash escape included), unescapes it, and
|
|
19
|
+
* re-runs the same matcher on it (bounded by depth) under a
|
|
20
|
+
* `shell-wrapper:<inner rule>` id. No upstream verdict changes.
|
|
21
|
+
*
|
|
22
|
+
* Matching is deliberately conservative: it is a review/escalation signal, never
|
|
23
|
+
* a substitute for `dsh-defend`, approvals, or the sandbox policy.
|
|
24
|
+
* @module dsh-autotier/guard-rules
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Command runners that may prefix `rm` and still execute it, split by whether
|
|
29
|
+
* they legitimately carry their own arguments before the command.
|
|
30
|
+
*
|
|
31
|
+
* `ARG_RUNNERS` allow any arguments before `rm` (`env -i rm -rf`,
|
|
32
|
+
* `timeout 5 rm -rf`, `xargs -0 rm -rf`); `DIRECT_RUNNERS` take the command
|
|
33
|
+
* immediately (`nohup rm -rf`), because allowing arguments there would
|
|
34
|
+
* false-positive on harmless forms like `nohup echo rm -rf`.
|
|
35
|
+
*/
|
|
36
|
+
const ARG_RUNNERS = 'sudo|env|timeout|nice|xargs|doas|setarch|stdbuf|ionice'
|
|
37
|
+
const DIRECT_RUNNERS = 'command|exec|busybox|nohup|pkexec'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `rm` at command position: start of string, after a command separator
|
|
41
|
+
* (`;`, `&`, `|`), or after one of the runners above. `\\?` makes the leading
|
|
42
|
+
* backslash escape (`\rm -rf`) optional rather than required.
|
|
43
|
+
*/
|
|
44
|
+
const RECURSIVE_FORCE_RM = new RegExp(
|
|
45
|
+
'(^|[;&|]\\s*' +
|
|
46
|
+
'|\\b(' + ARG_RUNNERS + ')\\s+(?:\\S+\\s+)*' +
|
|
47
|
+
'|\\b(' + DIRECT_RUNNERS + ')\\s+' +
|
|
48
|
+
')\\\\?rm(\\s+)',
|
|
49
|
+
'i',
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/** Command matches are trimmed and truncated to this many characters. */
|
|
53
|
+
const COMMAND_MATCH_LIMIT = 80
|
|
54
|
+
|
|
55
|
+
/** Path matches are truncated to this many characters (upstream: whole path). */
|
|
56
|
+
const PATH_MATCH_LIMIT = 120
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Shell wrappers whose `-c` payload must be re-scanned (upstream misses these).
|
|
60
|
+
* This table and {@link SHELL_WRAPPER_MAX_DEPTH} are the plugin's own extension,
|
|
61
|
+
* not part of the upstream rule port.
|
|
62
|
+
*/
|
|
63
|
+
export const SHELL_WRAPPERS: readonly string[] = ['sh', 'bash', 'zsh', 'dash', 'ksh']
|
|
64
|
+
|
|
65
|
+
/** Recursion depth bound for nested `sh -c "sh -c ..."` payloads. */
|
|
66
|
+
export const SHELL_WRAPPER_MAX_DEPTH = 3
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The wrapper-name alternation in every letter case (`SH`, `Bash`, `sh`, ...)
|
|
70
|
+
* WITHOUT making the flag cluster case-insensitive: `-C` must not count as a
|
|
71
|
+
* `-c` cluster.
|
|
72
|
+
*/
|
|
73
|
+
const WRAPPER_NAMES = SHELL_WRAPPERS
|
|
74
|
+
.map((word) => word.split('').map((letter) => `[${letter}${letter.toUpperCase()}]`).join(''))
|
|
75
|
+
.join('|')
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A wrapper invocation at command position: separator/start, an optional
|
|
79
|
+
* backslash escape, wrapper name (any letter case), a single-dash flag cluster
|
|
80
|
+
* containing `c` (`-c`, `-lc`, `-ec`, ... but never a long option such as
|
|
81
|
+
* `--command`, which has no single-dash cluster before the payload), then a
|
|
82
|
+
* double-quoted, single-quoted, or bare payload. The quoted forms accept
|
|
83
|
+
* backslash escapes so nested quoting survives; the captured content is
|
|
84
|
+
* unescaped before it is re-scanned.
|
|
85
|
+
*/
|
|
86
|
+
const SHELL_WRAPPER_C = new RegExp(
|
|
87
|
+
`(^|[;&|]\\s*)\\\\?(${WRAPPER_NAMES})\\s+-(?=[a-z]*c[a-z]*\\b)[a-z]+\\s+("((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'|(\\S+))`,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
/** A command runner at the very start, with optional backslash escape. */
|
|
91
|
+
const RUNNER_AT_START = new RegExp(`^\\s*\\\\?(${ARG_RUNNERS}|${DIRECT_RUNNERS})\\s+`)
|
|
92
|
+
|
|
93
|
+
/** An argument-carrying runner at the very start. */
|
|
94
|
+
const ARG_RUNNER_AT_START = new RegExp(`^\\s*\\\\?(${ARG_RUNNERS})\\s+`)
|
|
95
|
+
|
|
96
|
+
/** One leading whitespace-delimited token. */
|
|
97
|
+
const LEADING_TOKEN = /^(\S+)\s+/
|
|
98
|
+
|
|
99
|
+
/** One named high-impact rule. */
|
|
100
|
+
export interface GuardRule {
|
|
101
|
+
/** Stable kebab-case identifier, also reported in {@link GuardMatch.rule}. */
|
|
102
|
+
readonly id: string
|
|
103
|
+
/** Human-readable description of the destruction the rule protects against. */
|
|
104
|
+
readonly description: string
|
|
105
|
+
/** The upstream pattern, unchanged (no `g` flag, so matching is stateless). */
|
|
106
|
+
readonly pattern: RegExp
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A matched rule plus the exact text that matched. */
|
|
110
|
+
export interface GuardMatch {
|
|
111
|
+
/** The {@link GuardRule.id} that matched, or `shell-wrapper:<inner rule>`. */
|
|
112
|
+
readonly rule: string
|
|
113
|
+
/** The matched rule's description. */
|
|
114
|
+
readonly description: string
|
|
115
|
+
/**
|
|
116
|
+
* The matched text: for commands the matched substring trimmed and truncated
|
|
117
|
+
* to 80 characters; for paths the whole target path truncated to 120
|
|
118
|
+
* characters (upstream reports the path, not the regex substring).
|
|
119
|
+
*/
|
|
120
|
+
readonly matched: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The 16 upstream `HIGH_IMPACT_COMMAND` patterns, in upstream array order.
|
|
125
|
+
* `matchCommand` returns the first hit, so order is part of the contract:
|
|
126
|
+
* `sudo` precedes `wget-pipe-shell`, and the separate `rm` rule precedes all
|
|
127
|
+
* of these.
|
|
128
|
+
*/
|
|
129
|
+
export const HIGH_IMPACT_COMMAND_RULES: readonly GuardRule[] = [
|
|
130
|
+
{ id: 'mkfs', description: 'mkfs: filesystem creation on a device', pattern: /\bmkfs\.?[a-z]*\b/ },
|
|
131
|
+
{ id: 'dd-write', description: 'dd with if=/of=: raw device read/write', pattern: /\bdd\s+(if|of)=/ },
|
|
132
|
+
{ id: 'sudo', description: 'sudo: privilege escalation at command position', pattern: /(^|[;&|]\s*)sudo\b/ },
|
|
133
|
+
{ id: 'shutdown', description: 'shutdown/reboot/halt: system power control', pattern: /(^|[;&|]\s*)(shutdown|reboot|halt)\b/ },
|
|
134
|
+
{ id: 'git-push-force', description: 'git push --force/-f: remote history rewrite', pattern: /git\s+push\s+[^\n]*(-f\b|--force)/ },
|
|
135
|
+
{ id: 'git-clean-force', description: 'git clean -f: untracked file deletion', pattern: /git\s+clean\s+(-[a-z]*f[a-z]*\b)/ },
|
|
136
|
+
{ id: 'find-delete', description: 'find -delete: recursive file deletion', pattern: /find\s+[^\n]*\s+-delete\b/ },
|
|
137
|
+
{ id: 'find-exec-rm', description: 'find -exec rm: deletion through find', pattern: /find\s+[^\n]*-exec\s+[^\n]*\brm\b/ },
|
|
138
|
+
{ id: 'shutil-rmtree', description: 'shutil.rmtree(...): recursive Python deletion', pattern: /\b(shutil\.rmtree|rmtree)\s*\(/ },
|
|
139
|
+
{ id: 'os-remove', description: 'os.remove(...): Python file deletion', pattern: /\bos\.remove\s*\(/ },
|
|
140
|
+
{ id: 'python-inline-delete', description: 'python -c with inline deletion', pattern: /python[0-9.]*\s+-c\s+[^|;&\n]*(rmtree|os\.remove|shutil\.rmtree|rm\s+-rf)/ },
|
|
141
|
+
{ id: 'curl-pipe-shell', description: 'curl | sh: remote script execution', pattern: /curl\s+[^\n]*\|\s*(sudo\s+)?(ba)?sh\b/ },
|
|
142
|
+
{ id: 'wget-pipe-shell', description: 'wget | sh: remote script execution', pattern: /wget\s+[^\n]*\|\s*(sudo\s+)?(ba)?sh\b/ },
|
|
143
|
+
{ id: 'chmod-ssh', description: 'chmod on a .ssh/ path: SSH key permission change', pattern: /\bchmod\s+[0-7]{3,4}\s+[^\n]*\.ssh\// },
|
|
144
|
+
{ id: 'chown', description: 'chown: ownership change', pattern: /\bchown\s/ },
|
|
145
|
+
{ id: 'diskutil-erase', description: 'diskutil erase/unmount: macOS disk destruction', pattern: /\bdiskutil\s+(eraseDisk|eraseVolume|zeroDisk|secureErase|unmountDisk)\b/ },
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The 5 upstream `HIGH_IMPACT_PATH` patterns (credentials, keys, secrets), in
|
|
150
|
+
* upstream array order. `.env` matches unless the suffix is an
|
|
151
|
+
* example/sample/template name.
|
|
152
|
+
*/
|
|
153
|
+
export const HIGH_IMPACT_PATH_RULES: readonly GuardRule[] = [
|
|
154
|
+
{ id: 'dotenv', description: '.env secrets file (example/sample/template names excluded)', pattern: /(^|\/)\.env(\.(?!example|sample|template)[^/]*)?$/i },
|
|
155
|
+
{ id: 'credentials', description: 'credentials/secrets file or directory', pattern: /(^|\/)(credentials?|secrets?)(\.(json|ya?ml|toml|ini|env|key|pem|txt))?($|\/)/i },
|
|
156
|
+
{ id: 'ssh-dir', description: '.ssh/ directory', pattern: /(^|\/)\.ssh\// },
|
|
157
|
+
{ id: 'private-key', description: 'id_rsa/id_ed25519/id_ecdsa/id_dsa key or .netrc', pattern: /(^|\/)(id_(rsa|ed25519|ecdsa|dsa)|\.netrc)(\b|\/)/i },
|
|
158
|
+
{ id: 'key-file', description: '.pem/.key/.p12/.pfx/.jks key material', pattern: /\.(pem|key|p12|pfx|jks)$/i },
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
/** Description reported for the separate recursive-force `rm` rule. */
|
|
162
|
+
const RM_RECURSIVE_FORCE_DESCRIPTION = 'command pattern rm -r/-f matched (recursive force delete)'
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Match `rm` at command position with BOTH recursive (`-r`/`-R`/`--recursive`)
|
|
166
|
+
* and force (`-f`/`--force`) flags, including split flags (`rm -r -f`) that a
|
|
167
|
+
* single-token check misses.
|
|
168
|
+
* @param command - one shell command string.
|
|
169
|
+
* @returns the matched command-position text (trimmed, truncated), or null.
|
|
170
|
+
*/
|
|
171
|
+
function matchRecursiveForceRm(command: string): string | null {
|
|
172
|
+
const separator = command.match(RECURSIVE_FORCE_RM)
|
|
173
|
+
if (separator === null) return null
|
|
174
|
+
const head = separator[0] ?? ''
|
|
175
|
+
const rest = command.slice((separator.index ?? 0) + head.length)
|
|
176
|
+
let flags = ''
|
|
177
|
+
for (const token of rest.split(/\s+/)) {
|
|
178
|
+
if (/^--?[a-zA-Z]/.test(token)) flags += token.replace(/^-+/, '')
|
|
179
|
+
else break
|
|
180
|
+
}
|
|
181
|
+
const lowered = flags.toLowerCase()
|
|
182
|
+
if (!lowered.includes('r') || !lowered.includes('f')) return null
|
|
183
|
+
return head.trim().slice(0, COMMAND_MATCH_LIMIT)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Match the upstream command rules only: the recursive-force `rm` rule first
|
|
188
|
+
* (upstream order), then {@link HIGH_IMPACT_COMMAND_RULES} in array order.
|
|
189
|
+
*/
|
|
190
|
+
function matchUpstreamCommand(command: string): GuardMatch | null {
|
|
191
|
+
const rm = matchRecursiveForceRm(command)
|
|
192
|
+
if (rm !== null) {
|
|
193
|
+
return { rule: 'rm-recursive-force', description: RM_RECURSIVE_FORCE_DESCRIPTION, matched: rm }
|
|
194
|
+
}
|
|
195
|
+
for (const rule of HIGH_IMPACT_COMMAND_RULES) {
|
|
196
|
+
const match = command.match(rule.pattern)
|
|
197
|
+
if (match !== null) {
|
|
198
|
+
return {
|
|
199
|
+
rule: rule.id,
|
|
200
|
+
description: rule.description,
|
|
201
|
+
matched: (match[0] ?? '').trim().slice(0, COMMAND_MATCH_LIMIT),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Strip leading command runners (and the arguments an argument-carrying runner
|
|
210
|
+
* may carry) so a `sh -c ...` behind `env -i`, `timeout 5`, or `nohup` is still
|
|
211
|
+
* seen at command position. Argument consumption stops at a shell wrapper,
|
|
212
|
+
* matched case-insensitively and with the optional backslash escape removed.
|
|
213
|
+
*/
|
|
214
|
+
function stripRunnerPrefix(command: string): string {
|
|
215
|
+
let rest = command
|
|
216
|
+
for (;;) {
|
|
217
|
+
const carriesArgs = ARG_RUNNER_AT_START.test(rest)
|
|
218
|
+
const runner = RUNNER_AT_START.exec(rest)
|
|
219
|
+
if (runner === null) return rest
|
|
220
|
+
rest = rest.slice((runner[0] ?? '').length)
|
|
221
|
+
if (!carriesArgs) continue
|
|
222
|
+
for (;;) {
|
|
223
|
+
const token = LEADING_TOKEN.exec(rest)
|
|
224
|
+
const word = (token?.[1] ?? '').replace(/^\\/, '').toLowerCase()
|
|
225
|
+
if (token === null || SHELL_WRAPPERS.includes(word)) break
|
|
226
|
+
rest = rest.slice((token[0] ?? '').length)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The `-c` payload of the first shell-wrapper invocation, or null. */
|
|
232
|
+
function matchShellWrapperPayload(command: string): string | null {
|
|
233
|
+
const wrapper = SHELL_WRAPPER_C.exec(stripRunnerPrefix(command))
|
|
234
|
+
if (wrapper === null) return null
|
|
235
|
+
const payload = wrapper[4] ?? wrapper[5] ?? wrapper[6] ?? ''
|
|
236
|
+
return payload.replace(/\\(["'\\])/g, '$1')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The full matcher: upstream rules first, then the bounded shell-wrapper
|
|
241
|
+
* extension. `depth` counts wrapper unwrappings, so a payload nested deeper
|
|
242
|
+
* than {@link SHELL_WRAPPER_MAX_DEPTH} is left unmatched.
|
|
243
|
+
*/
|
|
244
|
+
function matchCommandAtDepth(command: string, depth: number): GuardMatch | null {
|
|
245
|
+
const upstream = matchUpstreamCommand(command)
|
|
246
|
+
if (upstream !== null) return upstream
|
|
247
|
+
if (depth >= SHELL_WRAPPER_MAX_DEPTH) return null
|
|
248
|
+
const payload = matchShellWrapperPayload(command)
|
|
249
|
+
if (payload === null) return null
|
|
250
|
+
const inner = matchCommandAtDepth(payload, depth + 1)
|
|
251
|
+
if (inner === null) return null
|
|
252
|
+
return {
|
|
253
|
+
rule: `shell-wrapper:${inner.rule}`,
|
|
254
|
+
description: `shell wrapper -c payload: ${inner.description}`,
|
|
255
|
+
matched: inner.matched.slice(0, COMMAND_MATCH_LIMIT),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* True for `rm` with BOTH recursive and force flags at command position (split
|
|
261
|
+
* flags included). Prose (`echo rm -rf`) and runner-argument false positives
|
|
262
|
+
* (`nohup echo rm -rf`) stay unmatched. Upstream-exact: this never unwraps a
|
|
263
|
+
* shell wrapper.
|
|
264
|
+
*/
|
|
265
|
+
export function hasRecursiveForceRm(command: string): boolean {
|
|
266
|
+
return matchRecursiveForceRm(command) !== null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Match one shell command string against the recursive-force `rm` rule first
|
|
271
|
+
* (upstream order) and then the command rules in array order; only when those
|
|
272
|
+
* find nothing, re-scan a `sh -c`-style payload (see the module header delta).
|
|
273
|
+
* @param command - one shell command string.
|
|
274
|
+
* @returns the first match, or null when the command is not high impact.
|
|
275
|
+
*/
|
|
276
|
+
export function matchCommand(command: string): GuardMatch | null {
|
|
277
|
+
return matchCommandAtDepth(command, 0)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Match one file path (a write/edit target) against the path rules in array
|
|
282
|
+
* order.
|
|
283
|
+
* @param filePath - one target path.
|
|
284
|
+
* @returns the first match, or null when the path is not high impact.
|
|
285
|
+
*/
|
|
286
|
+
export function matchPath(filePath: string): GuardMatch | null {
|
|
287
|
+
const rule = HIGH_IMPACT_PATH_RULES.find((candidate) => candidate.pattern.test(filePath))
|
|
288
|
+
if (rule === undefined) return null
|
|
289
|
+
return {
|
|
290
|
+
rule: rule.id,
|
|
291
|
+
description: rule.description,
|
|
292
|
+
matched: filePath.slice(0, PATH_MATCH_LIMIT),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Credential/secret path classification for protected-path review: true when
|
|
298
|
+
* any of the {@link HIGH_IMPACT_PATH_RULES} matches, i.e. exactly the paths
|
|
299
|
+
* {@link matchPath} reports.
|
|
300
|
+
*/
|
|
301
|
+
export function isCredentialPath(filePath: string): boolean {
|
|
302
|
+
return HIGH_IMPACT_PATH_RULES.some((rule) => rule.pattern.test(filePath))
|
|
303
|
+
}
|