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.
- package/README.md +25 -14
- package/assets/agents/ostacky.md +548 -501
- package/assets/commands/install-stack.md +2 -2
- package/assets/mcp/ostacky-controller/index.js +1277 -127
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/mcp/ostacky-controller/security.js +87 -0
- package/assets/plugins/engram.ts +41 -5
- package/assets/plugins/ostacky-guard.ts +212 -0
- package/assets/skills/brainstorming/SKILL.md +198 -197
- package/assets/skills/execution-mode-evaluation/SKILL.md +9 -1
- package/assets/skills/graceful-degradation/SKILL.md +251 -239
- package/assets/skills/using-git-worktrees/SKILL.md +8 -0
- package/dist/cli.js +417 -90
- package/manifest.json +32 -32
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/assets/plugins/engram.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|