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/guard.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The high-risk guard: a deterministic, tier-conditional denial on
3
+ * `tools/pre-execute`. It protects the cheap tier only — the strong model is
4
+ * the reviewer — and it never weakens `dsh-defend`, the approval service or the
5
+ * sandbox policy.
6
+ *
7
+ * Failure discipline: a guard that throws escalates the agent to the strong
8
+ * tier and lets the call through. Denying every call on a guard bug would turn
9
+ * one defect into a dead session; the escalation keeps the safety property
10
+ * (the strong model reviews) without breaking the turn.
11
+ *
12
+ * @module dsh-autotier/guard
13
+ */
14
+
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import type { Agent } from '@deepseek-ai/dsh-agent'
17
+ import type {} from '@deepseek-ai/dsh-tools'
18
+ import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
19
+ import type { ResolvedConfig } from './config.ts'
20
+ import { isCredentialPath, matchCommand, matchPath } from './guard-rules.ts'
21
+ import { noteFailure } from './policy.ts'
22
+ import type { AutotierService } from './service.ts'
23
+ import type { AgentStateStore } from './state.ts'
24
+ import type { TierId } from './types.ts'
25
+
26
+ /** Tool argument keys that carry a shell command. */
27
+ const COMMAND_KEYS = ['command', 'cmd', 'script'] as const
28
+
29
+ /**
30
+ * Tool argument keys that carry a filesystem target. `file_path` is specific
31
+ * enough to trust on any tool; the looser keys are only read from tools whose
32
+ * name says they write, so a tool with an unrelated `target` argument (a window
33
+ * handle, a selector) cannot trip the path rules.
34
+ */
35
+ const STRICT_PATH_KEYS = ['file_path'] as const
36
+ const LOOSE_PATH_KEYS = ['path', 'target', 'file', 'filename'] as const
37
+
38
+ /** Tool names whose `path`-like arguments are filesystem targets. */
39
+ const WRITE_TOOL_PATTERN = /(?:write|edit|patch|create|delete|remove|move|copy|rename|save|apply|mkdir|touch)/iu
40
+
41
+ /**
42
+ * Secret shapes that must never reach a model-visible denial reason or the
43
+ * session log. The matched snippet is the one place user text could leak into
44
+ * the guard's output, so it is redacted before it is composed.
45
+ */
46
+ const SECRET_PATTERNS: readonly RegExp[] = [
47
+ /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/giu,
48
+ /\bsk-[A-Za-z0-9._-]{8,}/gu,
49
+ /\bgh[pousr]_[A-Za-z0-9]{8,}/gu,
50
+ /((?:password|passwd|token|secret|api[_-]?key)\s*[=:]\s*)\S+/giu,
51
+ ]
52
+
53
+ /** Redact credential-shaped spans from one snippet. */
54
+ export function redactSnippet(text: string): string {
55
+ let redacted = text
56
+ for (const pattern of SECRET_PATTERNS) redacted = redacted.replace(pattern, '$1<redacted>')
57
+ return redacted
58
+ }
59
+
60
+ /** The guard's verdict for one call. */
61
+ export interface GuardVerdict {
62
+ readonly action: 'allow' | 'deny'
63
+ /** Empty for `allow`. */
64
+ readonly reason: string
65
+ /** The rule id that fired, or `''`. */
66
+ readonly rule: string
67
+ /** Which axis matched. */
68
+ readonly axis: 'command' | 'path' | 'protected-path' | 'none'
69
+ }
70
+
71
+ /** One call as the guard sees it. */
72
+ export interface GuardInput {
73
+ readonly toolName: string
74
+ readonly args: unknown
75
+ readonly tier: TierId
76
+ readonly config: ResolvedConfig
77
+ /** Resolved sandbox mode, when the policy service is composed. */
78
+ readonly sandboxMode?: string
79
+ }
80
+
81
+ /** Extract the first string-valued key present in `args`. */
82
+ function pick(args: unknown, keys: readonly string[]): string | undefined {
83
+ if (args === null || typeof args !== 'object') return undefined
84
+ const record = args as Record<string, unknown>
85
+ for (const key of keys) {
86
+ const value = record[key]
87
+ if (typeof value === 'string' && value.length > 0) return value
88
+ }
89
+ return undefined
90
+ }
91
+
92
+ /**
93
+ * Whether a path sits inside a configured protected surface. Windows resolves
94
+ * case-insensitively and strips trailing dots/spaces from each segment, so the
95
+ * comparison normalizes both before matching; otherwise `PACKAGE.JSON` and
96
+ * `package.json.` would slip past the guard.
97
+ */
98
+ function isProtectedPath(path: string, protectedPaths: readonly string[]): string | undefined {
99
+ const normalize = (value: string): string => {
100
+ const slashed = value.replace(/\\/gu, '/')
101
+ if (process.platform !== 'win32') return slashed
102
+ return slashed
103
+ .split('/')
104
+ .map(segment => segment.replace(/[. ]+$/u, '').toLowerCase())
105
+ .join('/')
106
+ }
107
+ const normalized = normalize(path)
108
+ for (const entry of protectedPaths) {
109
+ const needle = normalize(entry).replace(/^\.\//u, '')
110
+ if (normalized === needle || normalized.endsWith(`/${needle}`) || normalized.includes(`/${needle}/`)) return entry
111
+ }
112
+ return undefined
113
+ }
114
+
115
+ /** Whether a command or path is whitelisted (exact, or a path/word boundary prefix). */
116
+ function isWhitelisted(value: string | undefined, toolName: string, whitelist: readonly string[]): boolean {
117
+ if (whitelist.includes(toolName)) return true
118
+ if (value === undefined) return false
119
+ return whitelist.some((entry) => {
120
+ if (value === entry) return true
121
+ const rest = value.slice(entry.length)
122
+ if (!value.startsWith(entry) || rest === '') return false
123
+ // A prefix only whitelists on a real boundary, so `/tmp/scratch` does not
124
+ // cover `/tmp/scratch-malicious`.
125
+ return /^[\s/\\]/u.test(rest)
126
+ })
127
+ }
128
+
129
+ /**
130
+ * Judge one tool call.
131
+ *
132
+ * Order: the guard must be enabled and the executing tier protected, then the
133
+ * whitelist, then the credential/command rules, then the protected-path review
134
+ * rule. A denial names the rule and tells the model to escalate instead of
135
+ * retrying.
136
+ *
137
+ * @param input - the call, the executing tier and the live configuration.
138
+ * @returns the verdict.
139
+ */
140
+ export function evaluateToolCall(input: GuardInput): GuardVerdict {
141
+ const { config, tier } = input
142
+ if (!config.guard.enabled) return { action: 'allow', reason: '', rule: '', axis: 'none' }
143
+ if (!config.guard.tiers.includes('cheap') || tier !== 'cheap') {
144
+ return { action: 'allow', reason: '', rule: '', axis: 'none' }
145
+ }
146
+ const command = pick(input.args, COMMAND_KEYS)
147
+ // The path axis only applies to tools that write: a `read` of package.json or
148
+ // AGENTS.md is routine cheap-tier work, and upstream's rule set is explicit
149
+ // that reads are never intercepted.
150
+ const path = WRITE_TOOL_PATTERN.test(input.toolName)
151
+ ? pick(input.args, STRICT_PATH_KEYS) ?? pick(input.args, LOOSE_PATH_KEYS)
152
+ : undefined
153
+ if (isWhitelisted(command ?? path, input.toolName, config.guard.whitelist)) {
154
+ return { action: 'allow', reason: '', rule: '', axis: 'none' }
155
+ }
156
+ if (path !== undefined) {
157
+ const protectedEntry = isProtectedPath(path, config.guard.protectedPaths)
158
+ if (protectedEntry !== undefined) {
159
+ return {
160
+ action: 'deny',
161
+ rule: 'protected-path',
162
+ axis: 'protected-path',
163
+ reason: `dsh-autotier guard: "${redactSnippet(path)}" is a protected surface (${protectedEntry}). `
164
+ + 'Modifying it requires the strong tier; report what you intend to change instead of retrying.',
165
+ }
166
+ }
167
+ }
168
+ if (command !== undefined) {
169
+ const hit = matchCommand(command)
170
+ if (hit !== null) {
171
+ return {
172
+ action: 'deny',
173
+ rule: hit.rule,
174
+ axis: 'command',
175
+ reason: `dsh-autotier guard: ${redactSnippet(hit.description)}. This command is denied while the cheap tier executes; `
176
+ + 'the tier will escalate if the task needs it — do not retry the command.',
177
+ }
178
+ }
179
+ }
180
+ if (path !== undefined) {
181
+ const hit = matchPath(path)
182
+ if (hit !== null) {
183
+ return {
184
+ action: 'deny',
185
+ rule: hit.rule,
186
+ axis: 'path',
187
+ reason: `dsh-autotier guard: ${redactSnippet(hit.description)}. Credential and key material is denied while the cheap tier `
188
+ + 'executes; the tier will escalate if the task needs it.',
189
+ }
190
+ }
191
+ }
192
+ return { action: 'allow', reason: '', rule: '', axis: 'none' }
193
+ }
194
+
195
+ /** The tier the guard believes is executing one call. */
196
+ function tierOf(states: AgentStateStore, agent: Agent | undefined): TierId | undefined {
197
+ if (agent === undefined) return undefined
198
+ const state = states.for(agent)
199
+ // An explicit session override outranks the last applied tier, and an active
200
+ // escalation means the strong tier is reviewing.
201
+ if (state.override === 'strong') return 'strong'
202
+ if (state.override === 'off') return undefined
203
+ if (state.override === 'cheap') return 'cheap'
204
+ if (state.escalation !== undefined && state.escalation.until > Date.now()) return 'strong'
205
+ // Otherwise the tier of the request that produced this step is executing it.
206
+ return state.appliedTier ?? 'cheap'
207
+ }
208
+
209
+ /** Options for {@link registerGuardHook}. */
210
+ export interface GuardHookOptions {
211
+ readonly ctx: Context
212
+ readonly service: AutotierService
213
+ readonly states: AgentStateStore
214
+ }
215
+
216
+ /**
217
+ * Register the `tools/pre-execute` guard.
218
+ *
219
+ * The listener is registered with `{ prepend: true }` so a denial claims the
220
+ * call before any pass-through listener; every allowed call awaits `next()`.
221
+ *
222
+ * @param options - the plugin context, service and state store.
223
+ */
224
+ export function registerGuardHook({ ctx, service, states }: GuardHookOptions): void {
225
+ ctx.on('tools/pre-execute', async (exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision> => {
226
+ const agent = exec.agent
227
+ try {
228
+ const tier = tierOf(states, agent)
229
+ if (tier === undefined) return next()
230
+ const sandbox = ctx.get('sandboxPolicy') as { resolve(request: { session?: unknown }): { mode?: string } } | undefined
231
+ const sandboxMode = sandbox === undefined || agent === undefined
232
+ ? undefined
233
+ : sandbox.resolve({ session: agent.session }).mode
234
+ const verdict = evaluateToolCall({
235
+ toolName: exec.name,
236
+ args: exec.arguments,
237
+ tier,
238
+ config: service.config(),
239
+ ...sandboxMode === undefined ? {} : { sandboxMode },
240
+ })
241
+ if (verdict.action === 'allow') return next()
242
+ ctx.logger.warn(
243
+ 'dsh-autotier: guard denied %s on the %s tier (rule %s)%s',
244
+ exec.name,
245
+ tier,
246
+ verdict.rule,
247
+ sandboxMode === undefined ? '' : ` [sandbox ${sandboxMode}]`,
248
+ )
249
+ if (agent !== undefined) {
250
+ const state = states.for(agent)
251
+ state.denials += 1
252
+ state.lastDenial = verdict.rule
253
+ }
254
+ return { kind: 'deny', reason: verdict.reason }
255
+ } catch (error) {
256
+ // A broken guard is fail-closed for the call it was judging: the agent is
257
+ // forced onto the strong tier (where the guard does not apply) and the
258
+ // current call is denied. Letting it through would turn one defect into an
259
+ // open door, which is exactly what this guard exists to prevent.
260
+ ctx.logger.error('dsh-autotier: guard malfunction (%o); forcing escalation and denying the call', error)
261
+ if (agent !== undefined) {
262
+ const state = states.for(agent)
263
+ const config = service.config()
264
+ const now = Date.now()
265
+ noteFailure(state, `guard|${String(error)}`, config, now)
266
+ // Force the escalation immediately: one malfunction is enough.
267
+ state.escalation = {
268
+ count: config.escalation.threshold,
269
+ signature: `guard|${String(error)}`,
270
+ until: now + config.escalation.ttlMs,
271
+ rung: (state.escalation?.rung ?? 0) + 1,
272
+ lastAt: now,
273
+ }
274
+ }
275
+ return {
276
+ kind: 'deny',
277
+ reason: 'dsh-autotier guard: the guard itself failed, so this call was denied. '
278
+ + 'The session is escalated to the strong tier — re-run the call there.',
279
+ }
280
+ }
281
+ }, { prepend: true })
282
+ }
283
+
284
+ /** Re-exported for the status surface. */
285
+ export { isCredentialPath }
package/src/index.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * dsh-autotier: automatic strong/cheap model-tier routing for DeepSeek Harness.
3
+ *
4
+ * One user instruction enters, one tier decision comes out — with no manual
5
+ * model switching. Complex intent is planned on the strong tier and implemented
6
+ * on the cheap tier; simple intent is designed and implemented on the cheap
7
+ * tier directly. High-risk tool calls are denied while the cheap tier executes,
8
+ * and repeated failures escalate to the strong tier with a TTL fallback.
9
+ *
10
+ * The routing seam is the official `agent/request` waterfall: a listener
11
+ * registered at load time on the root scope with `{ prepend: true }` runs
12
+ * outermost, awaits `next()` exactly once, and returns a replacement
13
+ * `LlmCallConfig` (provider/model/effort plus the preserved sampling scalars).
14
+ *
15
+ * @module dsh-autotier
16
+ */
17
+
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import { registerTierCommand } from './command.ts'
20
+ import { Config, resolveConfig, validateConfig, type Config as AutotierConfig } from './config.ts'
21
+ import { registerGuardHook } from './guard.ts'
22
+ import { AutotierRouter } from './routing.ts'
23
+ import { AutotierService } from './service.ts'
24
+ import { AgentStateStore, registerTierProjection } from './state.ts'
25
+ import { registerTierTools } from './tools.ts'
26
+
27
+ export { Config, resolveConfig, validateConfig } from './config.ts'
28
+ export type { Config as AutotierConfig, ResolvedConfig } from './config.ts'
29
+ export type {
30
+ AutotierStatus,
31
+ CostMode,
32
+ EffortId,
33
+ RouteDecision,
34
+ RouteSource,
35
+ RoutingMode,
36
+ Scenario,
37
+ TierId,
38
+ TierRoute,
39
+ } from './types.ts'
40
+ export { EFFORT_IDS, ROUTING_MODES, SCENARIOS, TIER_IDS } from './types.ts'
41
+ export { AutotierService } from './service.ts'
42
+ export { AutotierRouter } from './routing.ts'
43
+ export type { RouteProposal, RouteVeto, TierChange } from './routing.ts'
44
+ export { AgentStateStore, registerTierProjection, TIER_PROJECTION_KEY } from './state.ts'
45
+ export {
46
+ classifyIntent,
47
+ compileRules,
48
+ computeSignals,
49
+ evaluateRules,
50
+ fingerprintOf,
51
+ PosteriorTable,
52
+ wilsonLowerBound,
53
+ } from './intent.ts'
54
+ export type { IntentInput, IntentResult, IntentSignals, Posterior, RuleHit } from './intent.ts'
55
+ export {
56
+ attemptBandApplies,
57
+ createRouteState,
58
+ decideTier,
59
+ escalationActive,
60
+ judgeNeeded,
61
+ noteFailure,
62
+ noteFallback,
63
+ noteJudgeCall,
64
+ } from './policy.ts'
65
+ export type { Decision, RouteState } from './policy.ts'
66
+ export {
67
+ advanceFallback,
68
+ classifyFallback,
69
+ effortRank,
70
+ escalationLadder,
71
+ fallbackActive,
72
+ nextEffortStep,
73
+ resolveRoute,
74
+ routeEquals,
75
+ } from './tiers.ts'
76
+ export type { EscalationRung, FallbackClass, FallbackRecord } from './tiers.ts'
77
+ export { JUDGE_LABELS, parseJudgeLabel, resolveJudgeRoute, runJudge } from './judge.ts'
78
+ export type { JudgeOutcome, JudgeRoute } from './judge.ts'
79
+ export { evaluateToolCall, redactSnippet, registerGuardHook } from './guard.ts'
80
+ export type { GuardInput, GuardVerdict } from './guard.ts'
81
+ export {
82
+ HIGH_IMPACT_COMMAND_RULES,
83
+ HIGH_IMPACT_PATH_RULES,
84
+ isCredentialPath,
85
+ matchCommand,
86
+ matchPath,
87
+ } from './guard-rules.ts'
88
+ export type { GuardMatch, GuardRule } from './guard-rules.ts'
89
+
90
+ /** The cordis.yml row id and the plugin name must match. */
91
+ export const name = 'dsh-autotier'
92
+
93
+ /**
94
+ * Hard service dependencies. `sessions` is plural — the service name really is
95
+ * `sessions` (`packages/core/session/src/index.ts` registers `super(ctx,
96
+ * 'sessions')`); declaring a non-existent name would leave this plugin PENDING
97
+ * forever. Every other capability (`agents`, `subagents`, `systemPrompt`,
98
+ * `planMode`, `sessionProjections`, `sandboxPolicy`) is read with `ctx.get()`
99
+ * and degrades when absent.
100
+ */
101
+ export const inject = ['settings', 'llm', 'tools', 'commands', 'sessions']
102
+
103
+ /**
104
+ * Mount the plugin: judge the configuration, register the `autotier` settings
105
+ * namespace, publish the `ctx.autotier` service, and wire the routing listeners,
106
+ * the `/tier` command and the two read-only tools.
107
+ *
108
+ * @param ctx - the plugin context.
109
+ * @param config - the raw row configuration; every field is optional.
110
+ * @throws {Error} when the configuration fails the cross-field judgement.
111
+ */
112
+ export function apply(ctx: Context, config: AutotierConfig = {}): void {
113
+ // Resolve first so a bad row fails at mount, before any namespace is
114
+ // registered (fail loud, and leave no half-mounted state behind).
115
+ const resolved = resolveConfig(config)
116
+ // Consumer: the plugin reads and validates the shared settings namespace.
117
+ const scope = ctx.settings.register('autotier', Config, {
118
+ base: config,
119
+ applies: 'live',
120
+ validate: (value) => {
121
+ validateConfig(value)
122
+ },
123
+ })
124
+ const service = new AutotierService(ctx, { scope, config: resolved })
125
+ registerTierProjection(ctx)
126
+ const states = new AgentStateStore()
127
+ new AutotierRouter({ ctx, service, states })
128
+ registerGuardHook({ ctx, service, states })
129
+ registerTierCommand(ctx, service, states)
130
+ registerTierTools(ctx, { service, states })
131
+ if (resolved.guard.interopDefend === 'auto') {
132
+ // Coexistence is deliberate: dsh-defend owns content scanning (injection,
133
+ // jailbreak, secrets) and its own recursive-delete gate; autotier adds
134
+ // tier-conditional denial and escalation guidance. Neither weakens the
135
+ // other, and pass-through discipline keeps both in the chain. dsh-defend
136
+ // provides no service, so this is stated rather than detected.
137
+ ctx.logger.info('dsh-autotier: guard runs alongside dsh-defend when installed (guard.interopDefend=auto)')
138
+ }
139
+ const status = service.status()
140
+ ctx.logger.info(
141
+ 'dsh-autotier: mode=%s strong=%s/%s cheap=%s/%s guard=%s',
142
+ status.mode,
143
+ status.tiers.strong.provider,
144
+ status.tiers.strong.model,
145
+ status.tiers.cheap.provider,
146
+ status.tiers.cheap.model,
147
+ status.guard.enabled ? 'on' : 'off',
148
+ )
149
+ }