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.
- package/README.md +21 -14
- package/assets/agents/ostacky.md +548 -524
- package/assets/commands/install-stack.md +2 -2
- package/assets/mcp/ostacky-controller/index.js +543 -173
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/mcp/ostacky-controller/security.js +87 -0
- package/assets/plugins/ostacky-guard.ts +111 -30
- package/assets/skills/brainstorming/SKILL.md +198 -197
- package/assets/skills/graceful-degradation/SKILL.md +251 -248
- package/dist/cli.js +231 -84
- package/manifest.json +30 -30
- 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
|
+
}
|
|
@@ -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
|
|
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") &&
|
|
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(
|
|
47
|
-
if (
|
|
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
|
-
//
|
|
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
|
}
|