ostacky 0.7.2 → 0.7.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * security.js — source-of-truth mirror of src/security.ts for controller (Node)
3
+ * Generado desde src/security.ts — mantener sincronizado via `bun run hash:check` y test controller-source
4
+ */
5
+
6
+ export const SENSITIVE_DEFAULT = [
7
+ "**/.env*",
8
+ "**/.secrets/**",
9
+ "**/*.pem",
10
+ "**/*.key",
11
+ "**/.aws/**",
12
+ "**/.ssh/**",
13
+ "**/credentials.json",
14
+ "**/.npmrc",
15
+ ];
16
+
17
+ export const BASH_SENSITIVE_RE =
18
+ /(?:^|[^a-zA-Z0-9_.-])(\.env(\b|[_.-])|\.secrets\b|\.pem\b|\.key\b|credentials\.json|\.aws\b|\.ssh\b|\.npmrc\b)/i;
19
+
20
+ export function isSensitive(filePath, patterns = SENSITIVE_DEFAULT) {
21
+ if (!filePath) return false;
22
+ const normalized = filePath.replace(/\\/g, "/");
23
+ const lower = normalized.toLowerCase();
24
+ if (
25
+ lower.endsWith(".env.example") ||
26
+ lower.endsWith(".env.template") ||
27
+ lower.endsWith(".env.sample")
28
+ )
29
+ return false;
30
+ const base = lower.split("/").pop() || "";
31
+ for (const pat of patterns) {
32
+ if (pat.includes(".env") && base.startsWith(".env")) return true;
33
+ if (pat.includes(".secrets") && lower.includes(".secrets")) return true;
34
+ if (pat.includes("*.pem") && lower.endsWith(".pem")) return true;
35
+ if (pat.includes("*.key") && lower.endsWith(".key")) return true;
36
+ if (pat.includes(".aws") && lower.includes(".aws")) return true;
37
+ if (pat.includes(".ssh") && lower.includes(".ssh")) return true;
38
+ if (pat.includes("credentials.json") && lower.endsWith("credentials.json")) return true;
39
+ if (pat.includes(".npmrc") && lower.endsWith(".npmrc")) return true;
40
+ }
41
+ if (/\.(pem|key)$/i.test(normalized)) return true;
42
+ if (base.startsWith(".env")) return true;
43
+ return false;
44
+ }
45
+
46
+ export function extractPathsFromBash(cmd) {
47
+ if (!cmd) return [];
48
+ const normalized = cmd.replace(/&&/g, ";").replace(/\|\|/g, ";");
49
+ const segments = normalized.split(/[|;><\n]+/);
50
+ const paths = [];
51
+ for (const seg of segments) {
52
+ const trimmed = seg.trim();
53
+ if (!trimmed) continue;
54
+ const tokens = trimmed.match(/(?:[^\s"'`\\]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) || [];
55
+ for (let token of tokens) {
56
+ const stripped = token.replace(/["'`]/g, "").replace(/\\/g, "");
57
+ if (!stripped) continue;
58
+ if (["cat","grep","ls","echo","awk","sed","cut","head","tail","wc","find","xargs","bash","sh","zsh","env","printenv","node","bun","npm","npx","ls"].includes(stripped)) continue;
59
+ if (stripped.startsWith("-")) continue;
60
+ const lower = stripped.toLowerCase();
61
+ if (
62
+ stripped.includes("/") ||
63
+ stripped.includes(".") ||
64
+ lower.startsWith(".env") ||
65
+ lower.includes(".secrets") ||
66
+ lower.endsWith(".pem") ||
67
+ lower.endsWith(".key") ||
68
+ lower.includes(".aws") ||
69
+ lower.includes(".ssh") ||
70
+ lower.endsWith("credentials.json") ||
71
+ lower.endsWith(".npmrc")
72
+ ) {
73
+ const cleaned = stripped.replace(/[,:;)\]]+$/, "");
74
+ if (cleaned) paths.push(cleaned);
75
+ } else if (stripped === ".env") {
76
+ paths.push(stripped);
77
+ }
78
+ }
79
+ }
80
+ return [...new Set(paths)];
81
+ }
82
+
83
+ export function getSensitivePatterns() {
84
+ const raw = process.env.OSTACKY_SENSITIVE_PATTERNS;
85
+ if (!raw) return SENSITIVE_DEFAULT;
86
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
87
+ }
@@ -208,6 +208,46 @@ function stripPrivateTags(str: string): string {
208
208
  return str.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]").trim()
209
209
  }
210
210
 
211
+ function stripJsoncComments(text: string): string {
212
+ let result = ""
213
+ let i = 0
214
+ let inString = false
215
+ while (i < text.length) {
216
+ const char = text[i]
217
+ const next = text[i + 1]
218
+ if (inString) {
219
+ if (char === "\\") {
220
+ result += char + (next ?? "")
221
+ i += 2
222
+ continue
223
+ }
224
+ if (char === '"') inString = false
225
+ result += char
226
+ i++
227
+ continue
228
+ }
229
+ if (char === '"') {
230
+ inString = true
231
+ result += char
232
+ i++
233
+ continue
234
+ }
235
+ if (char === "/" && next === "/") {
236
+ while (i < text.length && text[i] !== "\n") i++
237
+ continue
238
+ }
239
+ if (char === "/" && next === "*") {
240
+ i += 2
241
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++
242
+ i += 2
243
+ continue
244
+ }
245
+ result += char
246
+ i++
247
+ }
248
+ return result.replace(/,\s*([}\]])/g, "$1")
249
+ }
250
+
211
251
  // ─── Plugin Export ───────────────────────────────────────────────────────────
212
252
 
213
253
  export const Engram: Plugin = async (ctx) => {
@@ -556,11 +596,7 @@ export const Engram: Plugin = async (ctx) => {
556
596
  for (const cand of candidates) {
557
597
  try {
558
598
  const raw = readFileSync(cand, "utf-8")
559
- // strip // and /* */ comments for jsonc
560
- let j = raw
561
- .replace(/\/\/.*$/gm, "")
562
- .replace(/\/\*[\s\S]*?\*\//g, "")
563
- .replace(/,\s*([}\]])/g, "$1")
599
+ const j = stripJsoncComments(raw)
564
600
  const cfg = JSON.parse(j)
565
601
  const envPath = (cfg as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
566
602
  if (typeof envPath === "string" && envPath) {
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Ostacky Guard — plugin hermano de Engram
3
+ *
4
+ * Bloquea lecturas sensibles sin autorización previa y bloquea cualquier tool
5
+ * cuando el controller está en estado PENDING.
6
+ *
7
+ * Hard gate incluso en degraded: nunca uses bash para .env sin check_file_access → consume ALLOW con razón auditada.
8
+ *
9
+ * Se registra en `tool.execute.before` y lanza Error si la operación no está
10
+ * permitida. OpenCode aborta el tool antes de tocar disco.
11
+ */
12
+
13
+ import type { Plugin } from "@opencode-ai/plugin"
14
+ import { readFileSync, existsSync } from "node:fs"
15
+ import { join, dirname, basename, resolve, relative } from "node:path"
16
+
17
+ const SENSITIVE_DEFAULT = [
18
+ "**/.env*",
19
+ "**/.secrets/**",
20
+ "**/*.pem",
21
+ "**/*.key",
22
+ "**/.aws/**",
23
+ "**/.ssh/**",
24
+ "**/credentials.json",
25
+ "**/.npmrc",
26
+ ]
27
+
28
+ const BASH_SENSITIVE_RE =
29
+ /(?:^|[^a-zA-Z0-9_.-])(\.env(\b|[_.-])|\.secrets\b|\.pem\b|\.key\b|credentials\.json|\.aws\b|\.ssh\b|\.npmrc\b)/i
30
+
31
+ function getEnvPatterns(): string[] {
32
+ const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
33
+ if (!raw) return SENSITIVE_DEFAULT
34
+ return raw.split(",").map((s) => s.trim()).filter(Boolean)
35
+ }
36
+
37
+ function isSensitive(filePath: string, patterns: string[]): boolean {
38
+ if (!filePath) return false
39
+ const normalized = filePath.replace(/\\/g, "/")
40
+ const lower = normalized.toLowerCase()
41
+ if (lower.endsWith(".env.example") || lower.endsWith(".env.template") || lower.endsWith(".env.sample")) return false
42
+ const base = lower.split("/").pop() || ""
43
+ for (const pat of patterns) {
44
+ if (pat.includes(".env") && base.startsWith(".env")) return true
45
+ if (pat.includes(".secrets") && lower.includes(".secrets")) return true
46
+ if (pat.includes("*.pem") && lower.endsWith(".pem")) return true
47
+ if (pat.includes("*.key") && lower.endsWith(".key")) return true
48
+ if (pat.includes(".aws") && lower.includes(".aws")) return true
49
+ if (pat.includes(".ssh") && lower.includes(".ssh")) return true
50
+ if (pat.includes("credentials.json") && lower.endsWith("credentials.json")) return true
51
+ if (pat.includes(".npmrc") && lower.endsWith(".npmrc")) return true
52
+ }
53
+ if (/\.(pem|key)$/i.test(normalized)) return true
54
+ if (base.startsWith(".env")) return true
55
+ return false
56
+ }
57
+
58
+ function extractPathsFromBash(cmd: string): string[] {
59
+ if (!cmd) return []
60
+ const normalized = cmd.replace(/&&/g, ";").replace(/\|\|/g, ";")
61
+ const segments = normalized.split(/[|;><\n]+/)
62
+ const paths: string[] = []
63
+ for (const seg of segments) {
64
+ const trimmed = seg.trim()
65
+ if (!trimmed) continue
66
+ const tokens = trimmed.match(/(?:[^\s"'`\u0060\\]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) || []
67
+ for (let token of tokens) {
68
+ const stripped = token.replace(/["'`]/g, "").replace(/\\/g, "")
69
+ if (!stripped) continue
70
+ if (["cat","grep","ls","echo","awk","sed","cut","head","tail","wc","find","xargs","bash","sh","zsh","env","printenv","node","bun","npm","npx","ls","cat"].includes(stripped)) continue
71
+ if (stripped.startsWith("-")) continue
72
+ const lower = stripped.toLowerCase()
73
+ if (
74
+ stripped.includes("/") ||
75
+ stripped.includes(".") ||
76
+ lower.startsWith(".env") ||
77
+ lower.includes(".secrets") ||
78
+ lower.endsWith(".pem") ||
79
+ lower.endsWith(".key") ||
80
+ lower.includes(".aws") ||
81
+ lower.includes(".ssh") ||
82
+ lower.endsWith("credentials.json") ||
83
+ lower.endsWith(".npmrc")
84
+ ) {
85
+ const cleaned = stripped.replace(/[,:;)\]]+$/, "")
86
+ if (cleaned) paths.push(cleaned)
87
+ } else if (stripped === ".env") {
88
+ paths.push(stripped)
89
+ }
90
+ }
91
+ }
92
+ return [...new Set(paths)]
93
+ }
94
+
95
+ function getStatePath(directory: string): string {
96
+ if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
97
+ const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
98
+ for (const cand of candidates) {
99
+ try {
100
+ const raw = readFileSync(cand, "utf-8")
101
+ const json = JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
102
+ const envPath = (json as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
103
+ if (typeof envPath === "string" && envPath) return envPath
104
+ } catch {}
105
+ }
106
+ return join(directory, ".opencode", "ostacky-state.json")
107
+ }
108
+
109
+ function readState(directory: string): any | null {
110
+ const p = getStatePath(directory)
111
+ try {
112
+ const raw = readFileSync(p, "utf-8")
113
+ return JSON.parse(raw)
114
+ } catch {
115
+ return null
116
+ }
117
+ }
118
+
119
+ export const OstackyGuard: Plugin = async (ctx) => {
120
+ const patterns = getEnvPatterns()
121
+
122
+ return {
123
+ "tool.execute.before": async (input, output) => {
124
+ const tool = (input as any).tool as string
125
+ const args = (input as any).args as any
126
+
127
+ // 10.2: Guard genérico de PENDING — bloquear cualquier tool no-controller cuando estado es PENDING
128
+ const state = readState(ctx.directory)
129
+ const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
130
+ if (state && pendingStates.includes(state.state)) {
131
+ const allowedTools = ["consume_route_decision", "consume_execution_decision", "record_clarification", "abandon", "check_file_access", "consume_file_access_decision", "record_user_confirmation"]
132
+ const isControllerTool = tool.startsWith("ostacky-controller_") || allowedTools.some((t) => tool.includes(t))
133
+ if (!isControllerTool) {
134
+ throw new Error(`BLOCKED: call consume_* first — controller is in ${state.state}. Use consume_route_decision / consume_execution_decision / record_clarification to proceed.`)
135
+ }
136
+ }
137
+
138
+ // ── Guard hard para bash/write/edit (hardening-v2 P0) ──
139
+ // Hard gate incluso en degraded, nunca uses bash para .env sin check_file_access → consume ALLOW con razón auditada
140
+ if (tool === "bash") {
141
+ const cmd: string = args?.command || args?.cmd || ""
142
+ if (typeof cmd === "string" && cmd) {
143
+ const normalized = cmd.replace(/["'`]/g, "").replace(/\\/g, "")
144
+ const hasSensitivePattern = BASH_SENSITIVE_RE.test(normalized)
145
+ const paths = extractPathsFromBash(cmd)
146
+ const sensitivePaths = paths.filter((p) => isSensitive(p, patterns))
147
+ // Si hay match de regex o paths sensibles, validar ALLOW
148
+ if (hasSensitivePattern || sensitivePaths.length > 0) {
149
+ // Si extrajimos paths sensibles, validar cada uno
150
+ if (sensitivePaths.length > 0) {
151
+ for (const p of sensitivePaths) {
152
+ const s = readState(ctx.directory)
153
+ // Normalizar p para lookup: probar p tal cual, y resolved, y basename para casos relativos
154
+ const candidates = [p, resolve(ctx.directory, p), join(ctx.directory, p)]
155
+ const allowed = candidates.some((c) => s?.allowedFiles?.[c] || s?.allowedFiles?.[p])
156
+ // También chequear por basename si es .env (ej: .env vs /abs/.env)
157
+ const baseAllowed = s?.allowedFiles?.[p] || s?.allowedFiles?.[basename(p)] || s?.allowedFiles?.[resolve(ctx.directory, p)]
158
+ if (!allowed && !baseAllowed) {
159
+ const denied = s?.deniedFiles?.[p] || s?.deniedFiles?.[basename(p)]
160
+ if (denied) {
161
+ throw new Error(`BLOCKED: bash contiene acceso sensible (${p}) (previously denied) — Llamá check_file_access con reason antes.`)
162
+ }
163
+ throw new Error(`BLOCKED: bash contiene acceso sensible (${p}). Llamá check_file_access con reason antes.`)
164
+ }
165
+ }
166
+ } else if (hasSensitivePattern) {
167
+ // Fallback genérico cuando regex matchea pero no extrajimos path (ej: cat .env con espacios raros)
168
+ const s = readState(ctx.directory)
169
+ // Buscar si hay algún allowed que cubra .env* genérico
170
+ const hasAllowed = Object.keys(s?.allowedFiles || {}).some((k) => isSensitive(k, patterns))
171
+ if (!hasAllowed) {
172
+ throw new Error(`BLOCKED: bash contiene acceso sensible (.env). Llamá check_file_access con reason antes.`)
173
+ }
174
+ }
175
+ }
176
+ // Caso especial: obfuscación .e""nv → normalized ya quitó quotes, pero paths extraído ya lo cubre
177
+ // Si normalized contiene .env después de strip, y no hay allowed, ya bloqueamos arriba
178
+ }
179
+ }
180
+
181
+ // Extraer filePath para read/write/edit/grep/glob/read_mcp_resource
182
+ let filePath: string | undefined
183
+ if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob" || tool === "write" || tool === "edit") {
184
+ filePath = args?.filePath || args?.path || args?.pattern || args?.uri || ""
185
+ if (tool === "grep" && args?.include) filePath = args.include
186
+ if (tool === "write" || tool === "edit") {
187
+ filePath = args?.filePath || args?.path || ""
188
+ }
189
+ if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
190
+ try {
191
+ const u = args.uri as string
192
+ if (u.startsWith("file://")) filePath = u.slice(7)
193
+ else if (u.includes("/")) filePath = u
194
+ } catch {}
195
+ }
196
+ }
197
+
198
+ // 9.1: Guard de credenciales (hard gate incluso en degraded)
199
+ if (filePath && isSensitive(filePath, patterns)) {
200
+ const s = readState(ctx.directory)
201
+ const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(ctx.directory, filePath)] || s?.allowedFiles?.[basename(filePath)]
202
+ if (!allowed) {
203
+ const denied = s?.deniedFiles?.[filePath] || s?.deniedFiles?.[basename(filePath)]
204
+ if (denied) {
205
+ throw new Error(`BLOCKED: File ${filePath} requires check_file_access (previously denied)`)
206
+ }
207
+ throw new Error(`BLOCKED: File ${filePath} requires check_file_access`)
208
+ }
209
+ }
210
+ },
211
+ }
212
+ }