opencode-overclock 0.4.0 → 0.5.1

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 (50) hide show
  1. package/README.md +72 -17
  2. package/package.json +5 -3
  3. package/skills/codebase-design/DEEPENING.md +35 -0
  4. package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
  5. package/skills/codebase-design/SKILL.md +93 -0
  6. package/skills/diagnosing-bugs/SKILL.md +123 -0
  7. package/skills/domain-modeling/ADR-FORMAT.md +55 -0
  8. package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
  9. package/skills/domain-modeling/SKILL.md +102 -0
  10. package/skills/doubt/SKILL.md +80 -0
  11. package/skills/grilling/SKILL.md +96 -0
  12. package/skills/source-discipline/SKILL.md +78 -0
  13. package/skills/tdd/SKILL.md +87 -0
  14. package/skills/to-spec/SKILL.md +69 -0
  15. package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
  16. package/skills/to-tickets/SKILL.md +74 -0
  17. package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
  18. package/src/core/lifecycle.ts +18 -4
  19. package/src/core/types.ts +29 -0
  20. package/src/features/guard.ts +258 -12
  21. package/src/features/index.ts +13 -1
  22. package/src/features/recovery.ts +13 -3
  23. package/src/features/safety.ts +147 -0
  24. package/src/features/sched.ts +46 -10
  25. package/src/features/tasks.ts +87 -20
  26. package/src/features/truncator.ts +26 -9
  27. package/src/features/usage.ts +20 -0
  28. package/src/features/workflow.ts +270 -0
  29. package/src/lib/exec.ts +7 -1
  30. package/src/platform/process/exec.ts +252 -11
  31. package/src/platform/session/inject.ts +8 -1
  32. package/src/platform/storage/state.ts +26 -4
  33. package/src/v2/host.ts +4 -1
  34. package/src/workflow/agents/codebase-researcher.ts +27 -0
  35. package/src/workflow/agents/craftsman.ts +26 -0
  36. package/src/workflow/agents/design-explorer.ts +33 -0
  37. package/src/workflow/agents/doc-writer.ts +24 -0
  38. package/src/workflow/agents/doubt-reviewer.ts +26 -0
  39. package/src/workflow/agents/engineering-coach.ts +23 -0
  40. package/src/workflow/agents/performance-auditor.ts +29 -0
  41. package/src/workflow/agents/security-auditor.ts +23 -0
  42. package/src/workflow/agents/spec-reviewer.ts +15 -0
  43. package/src/workflow/agents/standards-reviewer.ts +24 -0
  44. package/src/workflow/agents/test-engineer.ts +28 -0
  45. package/src/workflow/catalog.ts +210 -0
  46. package/src/workflow/templates/build.ts +47 -0
  47. package/src/workflow/templates/define.ts +45 -0
  48. package/src/workflow/templates/diagnose.ts +58 -0
  49. package/src/workflow/templates/plan.ts +52 -0
  50. package/src/workflow/templates/ship.ts +64 -0
@@ -1,6 +1,7 @@
1
+ import { isAbsolute, relative } from "node:path"
1
2
  import type { FeatureModule } from "../types.ts"
2
3
  import { inject, toast } from "../lib/inject.ts"
3
- import { execBash } from "../lib/exec.ts"
4
+ import { execBash, redactSensitiveOutput, sanitizeEnv } from "../lib/exec.ts"
4
5
 
5
6
  export interface GuardHook {
6
7
  name: string
@@ -25,6 +26,150 @@ export const EDIT_ERROR_PATTERNS = [
25
26
  export const EDIT_RECOVERY_HINT =
26
27
  "\n\n[edit recovery hint]\nThe edit failed due to a content mismatch. Use the `read` tool to inspect the latest file state around the target lines before retrying the edit."
27
28
 
29
+ export interface FloorGuardOptions {
30
+ allowSkips?: boolean
31
+ allowSuppressions?: boolean
32
+ allowAssertionRemoval?: boolean
33
+ [key: string]: unknown
34
+ }
35
+
36
+ export interface FloorViolationPattern {
37
+ name: string
38
+ pattern: RegExp
39
+ description: string
40
+ testFileOnly?: boolean
41
+ }
42
+
43
+ export const FLOOR_VIOLATIONS: FloorViolationPattern[] = [
44
+ {
45
+ name: "test-skip-js",
46
+ pattern:
47
+ /(?:^|[.\s])(?:skip\s*\(|xit\s*\(|xdescribe\s*\()|\b(?:test\.skip|it\.skip|describe\.skip)\b/,
48
+ description: "Skipping test execution (.skip / xit / xdescribe)",
49
+ testFileOnly: true,
50
+ },
51
+ {
52
+ name: "test-skip-python",
53
+ pattern: /@pytest\.mark\.skip|@unittest\.skip/,
54
+ description: "Skipping test execution (@pytest.mark.skip / @unittest.skip)",
55
+ testFileOnly: true,
56
+ },
57
+ {
58
+ name: "test-skip-go",
59
+ pattern: /\bt\.Skip(?:\(|f\()/,
60
+ description: "Skipping test execution (t.Skip)",
61
+ testFileOnly: true,
62
+ },
63
+ {
64
+ name: "test-skip-rust",
65
+ pattern: /#\[ignore(?:\s*=.*)?\]/,
66
+ description: "Skipping test execution (#[ignore])",
67
+ testFileOnly: true,
68
+ },
69
+ {
70
+ name: "ts-suppression",
71
+ pattern:
72
+ /\/\/\s*@ts-(?:ignore|nocheck)\b|\/\*\s*@ts-(?:ignore|nocheck)\s*\*\/|\{\s*\/\*\s*@ts-(?:ignore|nocheck)\s*\*\/\s*\}/,
73
+ description: "TypeScript error suppression (@ts-ignore / @ts-nocheck)",
74
+ },
75
+ {
76
+ name: "eslint-suppression",
77
+ pattern: /\/\*?\s*eslint-disable(?:-next-line)?\b/,
78
+ description: "ESLint diagnostic suppression (eslint-disable)",
79
+ },
80
+ {
81
+ name: "python-suppression",
82
+ pattern: /#\s*(?:noqa|type:\s*ignore)\b/,
83
+ description: "Python diagnostic suppression (# noqa / # type: ignore)",
84
+ },
85
+ {
86
+ name: "empty-catch",
87
+ pattern: /catch\s*(?:\([^)]*\))?\s*\{\s*\}/,
88
+ description: "Empty catch block swallowing errors silently",
89
+ },
90
+ ]
91
+
92
+ export const ASSERTION_PATTERN =
93
+ /\b(?:expect\s*\(|assert\b|assert\.[a-zA-Z]+|assertEquals|assertTrue|assertFalse|self\.assert)/
94
+
95
+ export function isTestFile(filePath: string): boolean {
96
+ const normalized = filePath.toLowerCase().replaceAll("\\", "/")
97
+ const segments = normalized.split("/")
98
+ const filename = segments[segments.length - 1] ?? ""
99
+
100
+ return (
101
+ filename.includes(".test.") ||
102
+ filename.includes(".spec.") ||
103
+ segments.includes("test") ||
104
+ segments.includes("tests") ||
105
+ segments.includes("__tests__") ||
106
+ filename.endsWith("_test.go") ||
107
+ filename.endsWith("_test.py") ||
108
+ filename.endsWith("_spec.rb") ||
109
+ filename.startsWith("test_")
110
+ )
111
+ }
112
+
113
+ export function checkFloorViolation(
114
+ tool: string,
115
+ args: Record<string, unknown> | undefined,
116
+ options: FloorGuardOptions = {},
117
+ ): string | null {
118
+ const toolLower = tool.toLowerCase()
119
+ if (toolLower !== "edit" && toolLower !== "write") return null
120
+ if (!args) return null
121
+
122
+ const filePath =
123
+ typeof args.filePath === "string"
124
+ ? args.filePath
125
+ : typeof args.path === "string"
126
+ ? args.path
127
+ : typeof args.file_path === "string"
128
+ ? args.file_path
129
+ : ""
130
+
131
+ const isTest = isTestFile(filePath)
132
+
133
+ if (toolLower === "edit") {
134
+ const oldStr = typeof args.oldString === "string" ? args.oldString : ""
135
+ const newStr = typeof args.newString === "string" ? args.newString : ""
136
+
137
+ for (const v of FLOOR_VIOLATIONS) {
138
+ if (v.testFileOnly && !isTest) continue
139
+ if (v.testFileOnly && options.allowSkips) continue
140
+ if (!v.testFileOnly && options.allowSuppressions) continue
141
+
142
+ if (v.pattern.test(newStr) && !v.pattern.test(oldStr)) {
143
+ return `Detected ${v.description} in ${filePath || "edited file"}. Modifying code to bypass tests or suppress warnings is prohibited by floor-guard policy. Fix the underlying issue instead.`
144
+ }
145
+ }
146
+
147
+ if (!options.allowAssertionRemoval && isTest) {
148
+ if (
149
+ ASSERTION_PATTERN.test(oldStr) &&
150
+ !ASSERTION_PATTERN.test(newStr) &&
151
+ newStr.trim().length > 0
152
+ ) {
153
+ return `Stripped test assertion(s) from ${filePath || "test file"} without replacement. Deleting assertions to make tests pass is prohibited by floor-guard policy. Fix the implementation to satisfy the assertion.`
154
+ }
155
+ }
156
+ } else if (toolLower === "write") {
157
+ const content = typeof args.content === "string" ? args.content : ""
158
+
159
+ for (const v of FLOOR_VIOLATIONS) {
160
+ if (v.testFileOnly && !isTest) continue
161
+ if (v.testFileOnly && options.allowSkips) continue
162
+ if (!v.testFileOnly && options.allowSuppressions) continue
163
+
164
+ if (v.pattern.test(content)) {
165
+ return `Detected ${v.description} in ${filePath || "written file"}. Introducing test skips or error suppressions is prohibited by floor-guard policy.`
166
+ }
167
+ }
168
+ }
169
+
170
+ return null
171
+ }
172
+
28
173
  export function checkEditFailure(tool: string, outputText: string): string | null {
29
174
  if (tool.toLowerCase() !== "edit") return null
30
175
  const lower = outputText.toLowerCase()
@@ -112,6 +257,45 @@ export async function detectRecipes(directory: string): Promise<GuardHook[]> {
112
257
  return detected
113
258
  }
114
259
 
260
+ /**
261
+ * Patterns matching dangerous command idioms that are inappropriate for quality-gate hooks,
262
+ * such as reverse shells, remote script piping, and arbitrary socket relays.
263
+ */
264
+ export const DANGEROUS_COMMAND_PATTERNS: { pattern: RegExp; reason: string }[] = [
265
+ { pattern: /\/dev\/(?:tcp|udp)\//i, reason: "network socket redirection (/dev/tcp or /dev/udp)" },
266
+ { pattern: /\bmkfifo\b/i, reason: "named pipe creation (mkfifo)" },
267
+ {
268
+ pattern: /\b(?:nc|netcat)\b.*(?:\s+-e\s+|\s+-c\s+)/i,
269
+ reason: "netcat remote command execution flag (-e/-c)",
270
+ },
271
+ {
272
+ pattern: /\b(?:curl|wget|fetch)\b.*\|\s*(?:bash|sh|zsh|python|perl|ruby)\b/i,
273
+ reason: "remote script execution via pipe (curl/wget | shell)",
274
+ },
275
+ {
276
+ pattern: /\bbase64\s+(?:-d|--decode)\b.*\|\s*(?:bash|sh|zsh)\b/i,
277
+ reason: "encoded payload execution via pipe (base64 -d | shell)",
278
+ },
279
+ { pattern: /\bbash\s+-i\b.*>&/i, reason: "interactive reverse shell redirection (bash -i >&)" },
280
+ { pattern: /\bsocat\s+/i, reason: "socket relay execution (socat)" },
281
+ ]
282
+
283
+ /**
284
+ * Validates a hook command string against dangerous patterns.
285
+ * Returns the rejection reason or null if valid.
286
+ */
287
+ export function validateHookCommand(command: string): string | null {
288
+ for (const { pattern, reason } of DANGEROUS_COMMAND_PATTERNS) {
289
+ if (pattern.test(command)) {
290
+ return reason
291
+ }
292
+ }
293
+ return null
294
+ }
295
+
296
+ /** Maximum character length of failure payload tail to prevent prompt flooding. */
297
+ export const MAX_FAILURE_PAYLOAD_CHARS = 4000
298
+
115
299
  /** options.hooks -> validated GuardHook[]. Invalid entries -> console.warn, skipped, never throw. */
116
300
  export function parseHooks(raw: unknown): GuardHook[] {
117
301
  if (!Array.isArray(raw)) return []
@@ -124,7 +308,19 @@ export function parseHooks(raw: unknown): GuardHook[] {
124
308
  console.warn(`[overclock] guard: skipping invalid hook config: ${JSON.stringify(entry)}`)
125
309
  continue
126
310
  }
311
+
312
+ const danger = validateHookCommand(e.run)
313
+ if (danger) {
314
+ console.warn(`[overclock] guard: rejecting unsafe hook "${e.name}": ${danger}`)
315
+ continue
316
+ }
317
+
127
318
  const pathFilter = typeof e.pathFilter === "string" ? e.pathFilter : undefined
319
+ if (pathFilter && pathFilter.includes("..")) {
320
+ console.warn(`[overclock] guard: rejecting hook "${e.name}": pathFilter cannot contain ".."`)
321
+ continue
322
+ }
323
+
128
324
  hooks.push({
129
325
  name: e.name,
130
326
  tools: e.tools as string[],
@@ -132,10 +328,11 @@ export function parseHooks(raw: unknown): GuardHook[] {
132
328
  glob: pathFilter ? new Bun.Glob(pathFilter) : undefined,
133
329
  run: e.run,
134
330
  mode: e.mode === "append" ? "append" : "inject",
135
- debounceMs: typeof e.debounceMs === "number" ? e.debounceMs : 2000,
136
- timeoutMs: typeof e.timeoutMs === "number" ? e.timeoutMs : 60000,
331
+ debounceMs: typeof e.debounceMs === "number" ? Math.max(50, Math.min(e.debounceMs, 60000)) : 2000,
332
+ timeoutMs: typeof e.timeoutMs === "number" ? Math.max(100, Math.min(e.timeoutMs, 300000)) : 60000,
137
333
  onSuccess: e.onSuccess === "notify" ? "notify" : "silent",
138
- maxDeferMs: typeof e.maxDeferMs === "number" ? e.maxDeferMs : 300000,
334
+ maxDeferMs:
335
+ typeof e.maxDeferMs === "number" ? Math.max(1000, Math.min(e.maxDeferMs, 600000)) : 300000,
139
336
  })
140
337
  }
141
338
  return hooks
@@ -147,6 +344,7 @@ export function matchHook(
147
344
  toolName: string,
148
345
  filePath: string | undefined,
149
346
  resolveTool?: (name: string) => string,
347
+ cwd?: string,
150
348
  ): boolean {
151
349
  const matches = hook.tools.some((t) => {
152
350
  if (t === toolName) return true
@@ -156,18 +354,32 @@ export function matchHook(
156
354
  if (!matches) return false
157
355
  if (!hook.pathFilter) return true
158
356
  if (typeof filePath !== "string") return false
159
- return (hook.glob ?? new Bun.Glob(hook.pathFilter)).match(filePath)
357
+
358
+ const glob = hook.glob ?? new Bun.Glob(hook.pathFilter)
359
+ const normalized = filePath.replaceAll("\\", "/")
360
+ if (glob.match(normalized)) return true
361
+
362
+ if (cwd && isAbsolute(normalized)) {
363
+ const rel = relative(cwd, normalized).replaceAll("\\", "/")
364
+ if (glob.match(rel)) return true
365
+ }
366
+
367
+ return false
160
368
  }
161
369
 
162
- /** `[guard "<name>" failed (exit <code>)]` + last 40 lines of combined stdout+stderr. */
370
+ /** `[guard "<name>" failed (exit <code>)]` + sanitized last 40 lines of combined stdout+stderr. */
163
371
  export function failurePayload(name: string, code: number | null, combined: string): string {
164
- const tail = combined.split("\n").slice(-40).join("\n")
372
+ const sanitized = redactSensitiveOutput(combined)
373
+ let tail = sanitized.split("\n").slice(-40).join("\n")
374
+ if (tail.length > MAX_FAILURE_PAYLOAD_CHARS) {
375
+ tail = tail.slice(-MAX_FAILURE_PAYLOAD_CHARS) + "\n... [truncated for security & length]"
376
+ }
165
377
  return `\n\n[guard "${name}" failed (exit ${code})]\n${tail}`
166
378
  }
167
379
 
168
380
  function buildEnv(toolName: string, filePath: string | undefined): Record<string, string | undefined> {
169
381
  return {
170
- ...process.env,
382
+ ...sanitizeEnv(process.env),
171
383
  GUARD_TOOL: toolName,
172
384
  ...(filePath !== undefined ? { GUARD_FILE: filePath } : {}),
173
385
  }
@@ -179,7 +391,13 @@ async function runCommand(
179
391
  env: Record<string, string | undefined>,
180
392
  register?: (proc: Bun.Subprocess) => void,
181
393
  ): Promise<{ code: number | null; combined: string }> {
182
- return execBash(hook.run, { cwd, env, timeoutMs: hook.timeoutMs, onSpawn: register })
394
+ return execBash(hook.run, {
395
+ cwd,
396
+ env,
397
+ timeoutMs: hook.timeoutMs,
398
+ onSpawn: register,
399
+ sanitizeEnv: true,
400
+ })
183
401
  }
184
402
 
185
403
  interface HookState {
@@ -352,9 +570,18 @@ export const guard: FeatureModule = {
352
570
  hooks.push(...autoHooks)
353
571
  }
354
572
 
573
+ const floorGuardEnabled =
574
+ options.floorGuard === true ||
575
+ options.auto === true ||
576
+ (typeof options.floorGuard === "object" && options.floorGuard !== null)
577
+ const floorGuardOpts: FloorGuardOptions =
578
+ typeof options.floorGuard === "object" && options.floorGuard !== null
579
+ ? (options.floorGuard as FloorGuardOptions)
580
+ : {}
581
+
355
582
  const editRecovery =
356
583
  options.editRecovery === true || (hooks.length > 0 && options.editRecovery !== false)
357
- if (hooks.length === 0 && !editRecovery) return {}
584
+ if (hooks.length === 0 && !editRecovery && !floorGuardEnabled) return {}
358
585
 
359
586
  const runner =
360
587
  hooks.length > 0
@@ -380,13 +607,32 @@ export const guard: FeatureModule = {
380
607
  if (hint) output.output += hint
381
608
  }
382
609
 
610
+ if (floorGuardEnabled && typeof output.output === "string") {
611
+ const violation = checkFloorViolation(
612
+ input.tool,
613
+ input.args as Record<string, unknown> | undefined,
614
+ floorGuardOpts,
615
+ )
616
+ if (violation) {
617
+ output.output += `\n\n[overclock floor-guard warning]\n${violation}`
618
+ void toast(ctx.client, "guard: floor-guard violation detected", "warning")
619
+ }
620
+ }
621
+
383
622
  if (!runner || hooks.length === 0) return
384
623
 
385
624
  const args = input.args as Record<string, unknown> | undefined
386
- const filePath = typeof args?.filePath === "string" ? args.filePath : undefined
625
+ const filePath =
626
+ typeof args?.filePath === "string"
627
+ ? args.filePath
628
+ : typeof args?.path === "string"
629
+ ? args.path
630
+ : typeof args?.file_path === "string"
631
+ ? args.file_path
632
+ : undefined
387
633
 
388
634
  for (const hook of hooks) {
389
- if (!matchHook(hook, input.tool, filePath, shared?.toolName)) continue
635
+ if (!matchHook(hook, input.tool, filePath, shared?.toolName, ctx.directory)) continue
390
636
  if (hook.mode === "append") {
391
637
  const payload = await runner.runAppend(hook, input.tool, filePath)
392
638
  if (payload && typeof output.output === "string") output.output += payload
@@ -1,4 +1,6 @@
1
1
  import type { FeatureModule } from "../types.ts"
2
+ import { safety } from "./safety.ts"
3
+ import { workflow } from "./workflow.ts"
2
4
  import { tasks } from "./tasks.ts"
3
5
  import { sched } from "./sched.ts"
4
6
  import { guard } from "./guard.ts"
@@ -10,4 +12,14 @@ import { recovery } from "./recovery.ts"
10
12
  /**
11
13
  * Registry, ordered. Order = hook composition order.
12
14
  */
13
- export const features: FeatureModule[] = [tasks, sched, guard, usage, buddy, truncator, recovery]
15
+ export const features: FeatureModule[] = [
16
+ safety,
17
+ workflow,
18
+ tasks,
19
+ sched,
20
+ guard,
21
+ usage,
22
+ buddy,
23
+ truncator,
24
+ recovery,
25
+ ]
@@ -68,7 +68,11 @@ export function createRecoveryTracker(maxAttempts = 3, cooldownMs = 60_000): Rec
68
68
  canAttempt(sessionID: string): boolean {
69
69
  const now = Date.now()
70
70
  const list = (attempts.get(sessionID) ?? []).filter((t) => now - t < cooldownMs)
71
- attempts.set(sessionID, list)
71
+ if (list.length === 0) {
72
+ attempts.delete(sessionID)
73
+ } else {
74
+ attempts.set(sessionID, list)
75
+ }
72
76
  return list.length < maxAttempts
73
77
  },
74
78
  recordAttempt(sessionID: string): void {
@@ -101,8 +105,9 @@ export const recovery: FeatureModule = {
101
105
  return {
102
106
  event: async ({ event }) => {
103
107
  if (event.type === "session.deleted") {
104
- const props = event.properties as { info?: { id?: string } } | undefined
105
- if (props?.info?.id) tracker.reset(props.info.id)
108
+ const props = event.properties as { sessionID?: string; info?: { id?: string } } | undefined
109
+ const id = props?.sessionID ?? props?.info?.id
110
+ if (id) tracker.reset(id)
106
111
  return
107
112
  }
108
113
 
@@ -130,6 +135,11 @@ export const recovery: FeatureModule = {
130
135
  await toast(ctx.client, `recovering session from ${classified.reason}...`, "warning")
131
136
 
132
137
  if (autoResume) {
138
+ if (classified.reason === "rate_limit") {
139
+ // Apply a brief backoff delay before retrying a throttled endpoint
140
+ await new Promise((r) => setTimeout(r, 2000))
141
+ }
142
+
133
143
  const resumeText =
134
144
  classified.reason === "context_limit"
135
145
  ? "[session recovered: context limit reached; summarize recent progress and continue with minimal output]"
@@ -0,0 +1,147 @@
1
+ import type { FeatureModule } from "../types.ts"
2
+ import { toast } from "../lib/inject.ts"
3
+ import { shellQuote } from "../lib/exec.ts"
4
+
5
+ export interface DangerousPattern {
6
+ name: string
7
+ pattern: RegExp
8
+ reason: string
9
+ }
10
+
11
+ const GIT_PREFIX =
12
+ "\\bgit(?:\\s+(?:-[a-zA-Z0-9_.-]+|--[a-zA-Z0-9_.-]+(?:=\\S+)?|-[a-zA-Z]\\s+(?:\"[^\"]*\"|'[^']*'|\\S+)|--(?:git-dir|work-tree|namespace)\\s+(?:\"[^\"]*\"|'[^']*'|\\S+)))*\\s+"
13
+
14
+ export const DANGEROUS_GIT_PATTERNS: DangerousPattern[] = [
15
+ {
16
+ name: "force-push",
17
+ pattern: new RegExp(
18
+ `${GIT_PREFIX}push\\b[^;&|\\n]*(?:\\s(?:--force(?:-with-lease)?\\b|-f\\b)|\\s\\+[^\\s:]+(?::\\S+)?)`,
19
+ ),
20
+ reason: "Force-pushing can overwrite remote history.",
21
+ },
22
+ {
23
+ name: "hard-reset",
24
+ pattern: new RegExp(`${GIT_PREFIX}reset\\b[^;&|\\n]*(?:\\s|^)--hard\\b`),
25
+ reason: "Hard-reset discards uncommitted changes permanently.",
26
+ },
27
+ {
28
+ name: "force-clean",
29
+ pattern: new RegExp(`${GIT_PREFIX}clean\\b[^;&|\\n]*(?:\\s-[a-zA-Z]*f[a-zA-Z]*|\\s--force\\b)`),
30
+ reason: "Force clean deletes untracked files irreversibly.",
31
+ },
32
+ {
33
+ name: "branch-force-delete",
34
+ pattern: new RegExp(
35
+ `${GIT_PREFIX}branch\\b[^;&|\\n]*(?:\\s-[a-zA-Z]*D[a-zA-Z]*|\\s--delete\\s+--force\\b|\\s--force\\s+--delete\\b|\\s-[a-zA-Z]*d[a-zA-Z]*\\s+-[a-zA-Z]*f[a-zA-Z]*|\\s-[a-zA-Z]*f[a-zA-Z]*\\s+-[a-zA-Z]*d[a-zA-Z]*)`,
36
+ ),
37
+ reason: "Force deleting a branch bypasses unmerged commit checks.",
38
+ },
39
+ {
40
+ name: "remote-branch-delete",
41
+ pattern: new RegExp(`${GIT_PREFIX}push\\b[^;&|\\n]*(?:\\s--delete\\b|\\s-d\\b|\\s:[\\w/.-]+)`),
42
+ reason: "Deleting a remote branch can impact other collaborators.",
43
+ },
44
+ {
45
+ name: "discard-all-worktree",
46
+ pattern: new RegExp(
47
+ `${GIT_PREFIX}(?:restore|checkout)\\b[^;&|\\n]*?(?:\\s(?:--\\s+)?(?:\\.|\\*|:\\/))(?=\\s|$|[;&|])`,
48
+ ),
49
+ reason: "Discarding entire worktree changes loses all in-progress edits.",
50
+ },
51
+ {
52
+ name: "stash-destroy",
53
+ pattern: new RegExp(`${GIT_PREFIX}stash\\s+(?:drop|clear)\\b`),
54
+ reason: "Dropping or clearing stashes deletes saved work.",
55
+ },
56
+ {
57
+ name: "rebase-skip",
58
+ pattern: new RegExp(`${GIT_PREFIX}rebase\\s+--skip\\b`),
59
+ reason: "Rebase skip drops the conflicting commit completely.",
60
+ },
61
+ ]
62
+
63
+ export interface SafetyOptions {
64
+ blockDestructiveGit?: boolean
65
+ allowForcePush?: boolean
66
+ allowStashDrop?: boolean
67
+ customPatterns?: { name: string; pattern: string; reason: string }[]
68
+ [key: string]: unknown
69
+ }
70
+
71
+ export function resolvePatterns(options: SafetyOptions = {}): DangerousPattern[] {
72
+ let list = [...DANGEROUS_GIT_PATTERNS]
73
+
74
+ if (options.allowForcePush === true) {
75
+ list = list.filter((p) => p.name !== "force-push")
76
+ }
77
+
78
+ if (options.allowStashDrop === true) {
79
+ list = list.filter((p) => p.name !== "stash-destroy")
80
+ }
81
+
82
+ if (Array.isArray(options.customPatterns)) {
83
+ for (const c of options.customPatterns) {
84
+ if (typeof c.name === "string" && typeof c.pattern === "string") {
85
+ try {
86
+ list.push({
87
+ name: c.name,
88
+ pattern: new RegExp(c.pattern),
89
+ reason: typeof c.reason === "string" ? c.reason : "Blocked by custom safety policy.",
90
+ })
91
+ } catch (e) {
92
+ console.warn(`[overclock] safety: invalid custom pattern "${c.name}": ${e}`)
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ return list
99
+ }
100
+
101
+ export function checkDangerousCommand(
102
+ command: string,
103
+ patterns: DangerousPattern[],
104
+ ): DangerousPattern | null {
105
+ for (const p of patterns) {
106
+ if (p.pattern.test(command)) {
107
+ return p
108
+ }
109
+ }
110
+ return null
111
+ }
112
+
113
+ export const safety: FeatureModule = {
114
+ name: "safety",
115
+ tools: [],
116
+ defaultEnabled: true,
117
+ async init(ctx, options, shared) {
118
+ const opts = (options ?? {}) as SafetyOptions
119
+ if (opts.blockDestructiveGit === false) {
120
+ return {}
121
+ }
122
+
123
+ const patterns = resolvePatterns(opts)
124
+ const bashToolName = shared?.toolName ? shared.toolName("bash").toLowerCase() : "bash"
125
+
126
+ return {
127
+ "tool.execute.before": async (input, output) => {
128
+ const toolLower = input.tool.toLowerCase()
129
+ if (toolLower !== "bash" && toolLower !== bashToolName) return
130
+
131
+ const args = output.args as Record<string, unknown> | undefined
132
+ if (!args || typeof args.command !== "string") return
133
+
134
+ const matched = checkDangerousCommand(args.command, patterns)
135
+ if (!matched) return
136
+
137
+ const blockedMessage = `[overclock safety] Blocked destructive git command (${matched.name}): ${matched.reason}\nCommand requested: ${args.command}\nAction: To prevent accidental code loss, destructive git actions are blocked by overclock safety policy. Use safe alternatives like 'git stash push', 'git revert', or selective file restore.`
138
+
139
+ // Rewrite command to exit with error rather than crashing the execution fiber
140
+ // Use shellQuote to ensure no shell expansion/substitution occurs on the blocked command
141
+ output.args.command = `printf '%s\\n' ${shellQuote(blockedMessage)} >&2 && exit 1`
142
+
143
+ void toast(ctx.client, `safety: blocked ${matched.name}`, "warning")
144
+ },
145
+ }
146
+ },
147
+ }
@@ -11,12 +11,25 @@ const z = tool.schema
11
11
  export type Spec = { kind: "interval"; ms: number } | { kind: "cron"; expr: string }
12
12
 
13
13
  const UNITS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
14
+ export const MIN_INTERVAL_MS = 5000
15
+ export const MAX_SCHEDULES = 50
16
+ export const MAX_CONSECUTIVE_FAILURES = 5
14
17
 
15
- /** "30s" | "5m" | "2h" | "1d" -> interval; else cron expr (validated). Throws on garbage. */
18
+ /** "30s" | "5m" | "2h" | "1d" -> interval; else cron expr (validated). Throws on garbage or frequency < 5s. */
16
19
  export function parseSpec(spec: string): Spec {
17
20
  const m = spec.trim().match(/^(\d+)([smhd])$/)
18
- if (m) return { kind: "interval", ms: Number(m[1]) * UNITS[m[2]!]! }
19
- new Cron(spec) // throws if invalid
21
+ if (m) {
22
+ const ms = Number(m[1]) * UNITS[m[2]!]!
23
+ if (ms < MIN_INTERVAL_MS) {
24
+ throw new Error(`Schedule interval must be at least 5s (got ${spec})`)
25
+ }
26
+ return { kind: "interval", ms }
27
+ }
28
+ const cron = new Cron(spec) // throws if invalid
29
+ const runs = cron.nextRuns(2)
30
+ if (runs.length >= 2 && runs[1]!.getTime() - runs[0]!.getTime() < MIN_INTERVAL_MS) {
31
+ throw new Error(`Schedule frequency must be at least 5s (got ${spec})`)
32
+ }
20
33
  return { kind: "cron", expr: spec }
21
34
  }
22
35
 
@@ -34,6 +47,7 @@ export interface ScheduleManager {
34
47
  delete(id: string): Promise<boolean>
35
48
  arm(s: ScheduleEntry): void
36
49
  disarm(id: string): void
50
+ fire(s: ScheduleEntry): Promise<void>
37
51
  nextRun(s: ScheduleEntry): string
38
52
  dispose(): void
39
53
  }
@@ -52,10 +66,19 @@ export interface ScheduleManagerDeps {
52
66
  export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<ScheduleManager> {
53
67
  const schedules = new Map<string, ScheduleEntry>()
54
68
  const timers = new Map<string, { stop(): void }>()
69
+ const consecutiveFailures = new Map<string, number>()
55
70
  const skipIfBusy = deps.skipIfBusy !== false
56
71
 
57
72
  const persist = async () => writeJson(deps.storePath, [...schedules.values()])
58
73
 
74
+ async function remove(id: string): Promise<boolean> {
75
+ if (!schedules.delete(id)) return false
76
+ disarm(id)
77
+ consecutiveFailures.delete(id)
78
+ await persist()
79
+ return true
80
+ }
81
+
59
82
  async function fire(s: ScheduleEntry) {
60
83
  try {
61
84
  if (s.target === "current") {
@@ -64,7 +87,21 @@ export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<
64
87
  return
65
88
  }
66
89
  const ok = await inject(deps.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
67
- if (!ok) await toast(deps.client, `schedule ${s.id}: target session gone`, "warning")
90
+ if (!ok) {
91
+ const fails = (consecutiveFailures.get(s.id) ?? 0) + 1
92
+ consecutiveFailures.set(s.id, fails)
93
+ if (fails >= MAX_CONSECUTIVE_FAILURES) {
94
+ console.warn(
95
+ `[overclock] schedule ${s.id}: target session ${s.sessionID} unreachable ${fails} times, auto-removing`,
96
+ )
97
+ await toast(deps.client, `schedule ${s.id}: session unreachable, auto-removed`, "warning")
98
+ await remove(s.id)
99
+ return
100
+ }
101
+ await toast(deps.client, `schedule ${s.id}: target session gone`, "warning")
102
+ } else {
103
+ consecutiveFailures.delete(s.id)
104
+ }
68
105
  } else {
69
106
  const res = await deps.client.session.create({ body: { title: `sched:${s.id}` } })
70
107
  const id = res.data?.id
@@ -117,8 +154,12 @@ export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<
117
154
  schedules,
118
155
  arm,
119
156
  disarm,
157
+ fire,
120
158
  nextRun,
121
159
  async create(input) {
160
+ if (schedules.size >= MAX_SCHEDULES) {
161
+ throw new Error(`Maximum schedules limit reached (${MAX_SCHEDULES})`)
162
+ }
122
163
  parseSpec(input.spec)
123
164
  const s: ScheduleEntry = {
124
165
  id: `s-${crypto.randomUUID().slice(0, 6)}`,
@@ -136,12 +177,7 @@ export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<
136
177
  list() {
137
178
  return [...schedules.values()].map((s) => ({ schedule: s, next: nextRun(s) }))
138
179
  },
139
- async delete(id: string) {
140
- if (!schedules.delete(id)) return false
141
- disarm(id)
142
- await persist()
143
- return true
144
- },
180
+ delete: remove,
145
181
  dispose() {
146
182
  for (const id of [...timers.keys()]) disarm(id)
147
183
  },