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.
Files changed (60) hide show
  1. package/AGENTS.md +93 -0
  2. package/CHANGELOG.md +85 -0
  3. package/LICENSE +201 -0
  4. package/README.es.md +247 -0
  5. package/README.hi.md +241 -0
  6. package/README.md +245 -0
  7. package/README.pt.md +246 -0
  8. package/README.zh.md +221 -0
  9. package/SECURITY.md +55 -0
  10. package/THIRD_PARTY_NOTICES.md +63 -0
  11. package/cordis.patch.yml +125 -0
  12. package/docs/preset-row.md +61 -0
  13. package/docs/supporting-lanes.md +45 -0
  14. package/lib/index.js +2848 -0
  15. package/lib/types/command.d.ts +17 -0
  16. package/lib/types/command.d.ts.map +1 -0
  17. package/lib/types/config.d.ts +94 -0
  18. package/lib/types/config.d.ts.map +1 -0
  19. package/lib/types/guard-rules.d.ts +97 -0
  20. package/lib/types/guard-rules.d.ts.map +1 -0
  21. package/lib/types/guard.d.ts +70 -0
  22. package/lib/types/guard.d.ts.map +1 -0
  23. package/lib/types/index.d.ts +60 -0
  24. package/lib/types/index.d.ts.map +1 -0
  25. package/lib/types/intent.d.ts +179 -0
  26. package/lib/types/intent.d.ts.map +1 -0
  27. package/lib/types/judge.d.ts +50 -0
  28. package/lib/types/judge.d.ts.map +1 -0
  29. package/lib/types/policy.d.ts +109 -0
  30. package/lib/types/policy.d.ts.map +1 -0
  31. package/lib/types/routing.d.ts +135 -0
  32. package/lib/types/routing.d.ts.map +1 -0
  33. package/lib/types/schema.d.ts +134 -0
  34. package/lib/types/schema.d.ts.map +1 -0
  35. package/lib/types/service.d.ts +67 -0
  36. package/lib/types/service.d.ts.map +1 -0
  37. package/lib/types/state.d.ts +46 -0
  38. package/lib/types/state.d.ts.map +1 -0
  39. package/lib/types/tiers.d.ts +103 -0
  40. package/lib/types/tiers.d.ts.map +1 -0
  41. package/lib/types/tools.d.ts +26 -0
  42. package/lib/types/tools.d.ts.map +1 -0
  43. package/lib/types/types.d.ts +96 -0
  44. package/lib/types/types.d.ts.map +1 -0
  45. package/package.json +179 -0
  46. package/src/command.ts +73 -0
  47. package/src/config.ts +358 -0
  48. package/src/guard-rules.ts +303 -0
  49. package/src/guard.ts +285 -0
  50. package/src/index.ts +149 -0
  51. package/src/intent.ts +484 -0
  52. package/src/judge.ts +150 -0
  53. package/src/policy.ts +246 -0
  54. package/src/routing.ts +575 -0
  55. package/src/schema.ts +295 -0
  56. package/src/service.ts +131 -0
  57. package/src/state.ts +134 -0
  58. package/src/tiers.ts +212 -0
  59. package/src/tools.ts +128 -0
  60. 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
+ }