ostacky 0.7.3 → 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.3",
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
+ }
@@ -4,6 +4,8 @@
4
4
  * Bloquea lecturas sensibles sin autorización previa y bloquea cualquier tool
5
5
  * cuando el controller está en estado PENDING.
6
6
  *
7
+ * Hard gate incluso en degraded: nunca uses bash para .env sin check_file_access → consume ALLOW con razón auditada.
8
+ *
7
9
  * Se registra en `tool.execute.before` y lanza Error si la operación no está
8
10
  * permitida. OpenCode aborta el tool antes de tocar disco.
9
11
  */
@@ -23,6 +25,9 @@ const SENSITIVE_DEFAULT = [
23
25
  "**/.npmrc",
24
26
  ]
25
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
+
26
31
  function getEnvPatterns(): string[] {
27
32
  const raw = process.env.OSTACKY_SENSITIVE_PATTERNS
28
33
  if (!raw) return SENSITIVE_DEFAULT
@@ -31,10 +36,12 @@ function getEnvPatterns(): string[] {
31
36
 
32
37
  function isSensitive(filePath: string, patterns: string[]): boolean {
33
38
  if (!filePath) return false
34
- const lower = filePath.toLowerCase()
39
+ const normalized = filePath.replace(/\\/g, "/")
40
+ const lower = normalized.toLowerCase()
35
41
  if (lower.endsWith(".env.example") || lower.endsWith(".env.template") || lower.endsWith(".env.sample")) return false
42
+ const base = lower.split("/").pop() || ""
36
43
  for (const pat of patterns) {
37
- if (pat.includes(".env") && lower.split("/").pop()!.startsWith(".env")) return true
44
+ if (pat.includes(".env") && base.startsWith(".env")) return true
38
45
  if (pat.includes(".secrets") && lower.includes(".secrets")) return true
39
46
  if (pat.includes("*.pem") && lower.endsWith(".pem")) return true
40
47
  if (pat.includes("*.key") && lower.endsWith(".key")) return true
@@ -43,22 +50,54 @@ function isSensitive(filePath: string, patterns: string[]): boolean {
43
50
  if (pat.includes("credentials.json") && lower.endsWith("credentials.json")) return true
44
51
  if (pat.includes(".npmrc") && lower.endsWith(".npmrc")) return true
45
52
  }
46
- if (/\.(pem|key)$/i.test(filePath)) return true
47
- if (filePath.includes(".env")) {
48
- const base = filePath.split("/").pop() || ""
49
- if (base.startsWith(".env")) return true
50
- }
53
+ if (/\.(pem|key)$/i.test(normalized)) return true
54
+ if (base.startsWith(".env")) return true
51
55
  return false
52
56
  }
53
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
+
54
95
  function getStatePath(directory: string): string {
55
96
  if (process.env.OSTACKY_STATE_PATH) return process.env.OSTACKY_STATE_PATH
56
- // try opencode.json
57
97
  const candidates = [join(directory, "opencode.json"), join(directory, "opencode.jsonc")]
58
98
  for (const cand of candidates) {
59
99
  try {
60
100
  const raw = readFileSync(cand, "utf-8")
61
- // naive parse, ignore comments
62
101
  const json = JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
63
102
  const envPath = (json as any)?.mcp?.["ostacky-controller"]?.environment?.OSTACKY_STATE_PATH
64
103
  if (typeof envPath === "string" && envPath) return envPath
@@ -85,41 +124,83 @@ export const OstackyGuard: Plugin = async (ctx) => {
85
124
  const tool = (input as any).tool as string
86
125
  const args = (input as any).args as any
87
126
 
88
- // Extraer filePath de args según tool
89
- let filePath: string | undefined
90
- if (tool === "read" || tool === "read_mcp_resource" || tool === "grep" || tool === "glob") {
91
- filePath = args?.filePath || args?.path || args?.pattern || args?.uri || ""
92
- // Para grep, el pattern puede ser contenido, no file; intentar detectar file
93
- if (tool === "grep" && args?.include) filePath = args.include
94
- if (tool === "read_mcp_resource" && typeof args?.uri === "string") {
95
- // uri puede ser file:// o path
96
- try {
97
- const u = args.uri as string
98
- if (u.startsWith("file://")) filePath = u.slice(7)
99
- else if (u.includes("/") ) filePath = u
100
- } catch {}
101
- }
102
- }
103
-
104
127
  // 10.2: Guard genérico de PENDING — bloquear cualquier tool no-controller cuando estado es PENDING
105
128
  const state = readState(ctx.directory)
106
129
  const pendingStates = ["ROUTE_DECISION_PENDING", "EXECUTION_DECISION_PENDING", "CLARIFICATION_PENDING"]
107
130
  if (state && pendingStates.includes(state.state)) {
108
- // Permitir solo tools del controller que desbloquean
109
131
  const allowedTools = ["consume_route_decision", "consume_execution_decision", "record_clarification", "abandon", "check_file_access", "consume_file_access_decision", "record_user_confirmation"]
110
132
  const isControllerTool = tool.startsWith("ostacky-controller_") || allowedTools.some((t) => tool.includes(t))
111
133
  if (!isControllerTool) {
112
- // Bloquear cualquier tool no-controller (Read/Grep/Glob/Bash/Edit/Write) cuando está en PENDING — hard gate genérico
113
134
  throw new Error(`BLOCKED: call consume_* first — controller is in ${state.state}. Use consume_route_decision / consume_execution_decision / record_clarification to proceed.`)
114
135
  }
115
136
  }
116
137
 
117
- // 9.1: Guard de credenciales
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)
118
199
  if (filePath && isSensitive(filePath, patterns)) {
119
200
  const s = readState(ctx.directory)
120
- const allowed = s?.allowedFiles?.[filePath]
201
+ const allowed = s?.allowedFiles?.[filePath] || s?.allowedFiles?.[resolve(ctx.directory, filePath)] || s?.allowedFiles?.[basename(filePath)]
121
202
  if (!allowed) {
122
- const denied = s?.deniedFiles?.[filePath]
203
+ const denied = s?.deniedFiles?.[filePath] || s?.deniedFiles?.[basename(filePath)]
123
204
  if (denied) {
124
205
  throw new Error(`BLOCKED: File ${filePath} requires check_file_access (previously denied)`)
125
206
  }