@danielblomma/cortex-mcp 1.7.1 → 2.0.2
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/bin/cortex.mjs +679 -32
- package/bin/style.mjs +349 -0
- package/package.json +4 -3
- package/scaffold/mcp/package-lock.json +834 -671
- package/scaffold/mcp/package.json +1 -1
- package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
- package/scaffold/mcp/src/cli/govern.ts +987 -0
- package/scaffold/mcp/src/cli/run.ts +306 -0
- package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
- package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
- package/scaffold/mcp/src/core/audit/query.ts +81 -0
- package/scaffold/mcp/src/core/audit/writer.ts +68 -0
- package/scaffold/mcp/src/core/config.ts +329 -0
- package/scaffold/mcp/src/core/index.ts +34 -0
- package/scaffold/mcp/src/core/license.ts +202 -0
- package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
- package/scaffold/mcp/src/core/policy/injection.ts +229 -0
- package/scaffold/mcp/src/core/policy/store.ts +197 -0
- package/scaffold/mcp/src/core/rbac/check.ts +40 -0
- package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
- package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
- package/scaffold/mcp/src/core/validators/config.ts +47 -0
- package/scaffold/mcp/src/core/validators/engine.ts +199 -0
- package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
- package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
- package/scaffold/mcp/src/daemon/client.ts +155 -0
- package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
- package/scaffold/mcp/src/daemon/main.ts +300 -0
- package/scaffold/mcp/src/daemon/paths.ts +41 -0
- package/scaffold/mcp/src/daemon/protocol.ts +101 -0
- package/scaffold/mcp/src/daemon/server.ts +227 -0
- package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
- package/scaffold/mcp/src/embed.ts +1 -1
- package/scaffold/mcp/src/embeddings.ts +1 -1
- package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
- package/scaffold/mcp/src/enterprise/index.ts +415 -0
- package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
- package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
- package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
- package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
- package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
- package/scaffold/mcp/src/hooks/session-end.ts +73 -0
- package/scaffold/mcp/src/hooks/session-start.ts +78 -0
- package/scaffold/mcp/src/hooks/shared.ts +134 -0
- package/scaffold/mcp/src/hooks/stop.ts +60 -0
- package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
- package/scaffold/mcp/src/plugin.ts +150 -0
- package/scaffold/mcp/src/server.ts +218 -7
- package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
- package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
- package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
- package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
- package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
- package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
- package/scaffold/mcp/tests/govern.test.mjs +74 -0
- package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
- package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
- package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
- package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
- package/scaffold/mcp/tests/run.test.mjs +109 -0
- package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
- package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
- package/scaffold/scripts/bootstrap.sh +0 -11
- package/scaffold/scripts/doctor.sh +24 -4
- package/types.js +5 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy enforcement layer.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the injection scanner with the policy store — only runs the
|
|
5
|
+
* scanner when the `prompt-injection-defense` policy is active.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OrgPolicy } from "./store.js";
|
|
9
|
+
import { scanForInjection, sanitizeContent, type InjectionMatch, type ScanResult } from "./injection.js";
|
|
10
|
+
|
|
11
|
+
const RULE_ID = "prompt-injection-defense";
|
|
12
|
+
|
|
13
|
+
export type EnforcementResult = {
|
|
14
|
+
allowed: boolean;
|
|
15
|
+
ruleId: string;
|
|
16
|
+
scan: ScanResult;
|
|
17
|
+
sanitized?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check whether the prompt-injection-defense policy is active.
|
|
22
|
+
*/
|
|
23
|
+
export function isInjectionDefenseActive(policies: OrgPolicy[]): boolean {
|
|
24
|
+
return policies.some((p) => p.id === RULE_ID && p.enforce);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Enforce the prompt-injection-defense policy against a piece of text.
|
|
29
|
+
*
|
|
30
|
+
* If the policy is not active the text is always allowed.
|
|
31
|
+
*
|
|
32
|
+
* @param text The text to check
|
|
33
|
+
* @param policies The current merged policy list
|
|
34
|
+
* @param options Optional: pass `sanitize: true` to get a sanitised version of the text
|
|
35
|
+
* @returns EnforcementResult
|
|
36
|
+
*/
|
|
37
|
+
export function enforceInjectionPolicy(
|
|
38
|
+
text: string,
|
|
39
|
+
policies: OrgPolicy[],
|
|
40
|
+
options?: { sanitize?: boolean },
|
|
41
|
+
): EnforcementResult {
|
|
42
|
+
if (!isInjectionDefenseActive(policies)) {
|
|
43
|
+
return {
|
|
44
|
+
allowed: true,
|
|
45
|
+
ruleId: RULE_ID,
|
|
46
|
+
scan: { score: 0, flagged: false, matches: [] },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const scan = scanForInjection(text);
|
|
51
|
+
|
|
52
|
+
const result: EnforcementResult = {
|
|
53
|
+
allowed: !scan.flagged,
|
|
54
|
+
ruleId: RULE_ID,
|
|
55
|
+
scan,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (options?.sanitize && scan.flagged) {
|
|
59
|
+
result.sanitized = sanitizeContent(text);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a violation payload compatible with the cortex-web
|
|
67
|
+
* `POST /api/v1/violations/push` endpoint.
|
|
68
|
+
*/
|
|
69
|
+
export function buildViolationPayload(
|
|
70
|
+
matches: InjectionMatch[],
|
|
71
|
+
context: { filePath?: string; query?: string },
|
|
72
|
+
): {
|
|
73
|
+
rule_id: string;
|
|
74
|
+
severity: "error" | "warning" | "info";
|
|
75
|
+
message: string;
|
|
76
|
+
file_path?: string;
|
|
77
|
+
metadata?: string;
|
|
78
|
+
occurred_at: string;
|
|
79
|
+
} {
|
|
80
|
+
const topMatch = matches[0];
|
|
81
|
+
const score = matches.reduce((s, m) => s + m.weight, 0).toFixed(2);
|
|
82
|
+
const message = `Prompt injection detected: ${topMatch.category} (score ${score})`;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
rule_id: RULE_ID,
|
|
86
|
+
severity: "warning",
|
|
87
|
+
message: message.slice(0, 2000),
|
|
88
|
+
file_path: context.filePath?.slice(0, 500),
|
|
89
|
+
metadata: JSON.stringify({
|
|
90
|
+
query_present: Boolean(context.query),
|
|
91
|
+
query_length: context.query?.length ?? 0,
|
|
92
|
+
match_count: matches.length,
|
|
93
|
+
categories: [...new Set(matches.map((m) => m.category))],
|
|
94
|
+
patterns: matches.map((m) => m.pattern),
|
|
95
|
+
}).slice(0, 5000),
|
|
96
|
+
occurred_at: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt injection scanner.
|
|
3
|
+
*
|
|
4
|
+
* Detects common prompt injection patterns in text and returns a risk
|
|
5
|
+
* score with categorised matches. Designed to run on every piece of
|
|
6
|
+
* content that flows between the file system and an LLM.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type InjectionCategory =
|
|
12
|
+
| "instruction_override"
|
|
13
|
+
| "role_play"
|
|
14
|
+
| "delimiter_escape"
|
|
15
|
+
| "instruction_marker"
|
|
16
|
+
| "encoded_payload";
|
|
17
|
+
|
|
18
|
+
export type InjectionMatch = {
|
|
19
|
+
pattern: string;
|
|
20
|
+
category: InjectionCategory;
|
|
21
|
+
matched: string;
|
|
22
|
+
position: number;
|
|
23
|
+
weight: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ScanResult = {
|
|
27
|
+
score: number;
|
|
28
|
+
flagged: boolean;
|
|
29
|
+
matches: InjectionMatch[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── Pattern definitions ────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
type PatternDef = {
|
|
35
|
+
regex: RegExp;
|
|
36
|
+
category: InjectionCategory;
|
|
37
|
+
pattern: string;
|
|
38
|
+
weight: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const PATTERNS: PatternDef[] = [
|
|
42
|
+
// ── Instruction overrides ──
|
|
43
|
+
{
|
|
44
|
+
regex: /ignore\s+(all\s+)?previous\s+instructions/gi,
|
|
45
|
+
category: "instruction_override",
|
|
46
|
+
pattern: "ignore_previous_instructions",
|
|
47
|
+
weight: 0.5,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
regex: /disregard\s+(all\s+)?(above|previous|prior|earlier)/gi,
|
|
51
|
+
category: "instruction_override",
|
|
52
|
+
pattern: "disregard_previous",
|
|
53
|
+
weight: 0.5,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
regex: /forget\s+(all\s+)?(above|previous|prior|earlier)\s+(instructions|context|rules)/gi,
|
|
57
|
+
category: "instruction_override",
|
|
58
|
+
pattern: "forget_previous",
|
|
59
|
+
weight: 0.5,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
regex: /new\s+instructions?\s*:/gi,
|
|
63
|
+
category: "instruction_override",
|
|
64
|
+
pattern: "new_instructions",
|
|
65
|
+
weight: 0.4,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
regex: /you\s+are\s+now\s+/gi,
|
|
69
|
+
category: "instruction_override",
|
|
70
|
+
pattern: "you_are_now",
|
|
71
|
+
weight: 0.4,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
regex: /from\s+now\s+on\s*,?\s+(you|ignore|do\s+not)/gi,
|
|
75
|
+
category: "instruction_override",
|
|
76
|
+
pattern: "from_now_on",
|
|
77
|
+
weight: 0.4,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
regex: /override\s+(all\s+)?(system|safety|previous)\s+(prompt|instructions|rules)/gi,
|
|
81
|
+
category: "instruction_override",
|
|
82
|
+
pattern: "override_system",
|
|
83
|
+
weight: 0.5,
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// ── Role-play / persona attacks ──
|
|
87
|
+
{
|
|
88
|
+
regex: /act\s+as\s+(an?\s+)?(unrestricted|unfiltered|evil|malicious|jailbroken)/gi,
|
|
89
|
+
category: "role_play",
|
|
90
|
+
pattern: "act_as_unrestricted",
|
|
91
|
+
weight: 0.5,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
regex: /pretend\s+you\s+are\s+(an?\s+)?(different|new|unrestricted)/gi,
|
|
95
|
+
category: "role_play",
|
|
96
|
+
pattern: "pretend_you_are",
|
|
97
|
+
weight: 0.4,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
regex: /enter\s+(DAN|developer|god|sudo|admin)\s+mode/gi,
|
|
101
|
+
category: "role_play",
|
|
102
|
+
pattern: "special_mode",
|
|
103
|
+
weight: 0.5,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
regex: /jailbreak/gi,
|
|
107
|
+
category: "role_play",
|
|
108
|
+
pattern: "jailbreak_keyword",
|
|
109
|
+
weight: 0.4,
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// ── Delimiter escapes ──
|
|
113
|
+
{
|
|
114
|
+
regex: /<\/?(system|assistant|user|tool_result|human|function_call|antml:)[\s>]/gi,
|
|
115
|
+
category: "delimiter_escape",
|
|
116
|
+
pattern: "xml_tag_escape",
|
|
117
|
+
weight: 0.5,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
regex: /\[INST\]|\[\/INST\]|\[SYS\]|\[\/SYS\]/gi,
|
|
121
|
+
category: "delimiter_escape",
|
|
122
|
+
pattern: "inst_tag_escape",
|
|
123
|
+
weight: 0.5,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
regex: /```\s*(system|prompt|instructions)/gi,
|
|
127
|
+
category: "delimiter_escape",
|
|
128
|
+
pattern: "code_block_escape",
|
|
129
|
+
weight: 0.3,
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// ── Instruction markers ──
|
|
133
|
+
{
|
|
134
|
+
regex: /^\s*(IMPORTANT|SYSTEM|ADMIN|OVERRIDE|INSTRUCTION)\s*:/gim,
|
|
135
|
+
category: "instruction_marker",
|
|
136
|
+
pattern: "authority_marker",
|
|
137
|
+
weight: 0.3,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
regex: /\bdo\s+not\s+follow\s+(any\s+)?(previous|prior|above)\s+(rules|instructions|guidelines)/gi,
|
|
141
|
+
category: "instruction_marker",
|
|
142
|
+
pattern: "do_not_follow",
|
|
143
|
+
weight: 0.5,
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// ── Encoded payloads ──
|
|
147
|
+
{
|
|
148
|
+
regex: /(?:eval|atob|Buffer\.from)\s*\(\s*["'`][A-Za-z0-9+/=]{20,}["'`]\s*\)/g,
|
|
149
|
+
category: "encoded_payload",
|
|
150
|
+
pattern: "encoded_eval",
|
|
151
|
+
weight: 0.4,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
regex: /&#x[0-9a-f]{2,4};/gi,
|
|
155
|
+
category: "encoded_payload",
|
|
156
|
+
pattern: "html_entity_encode",
|
|
157
|
+
weight: 0.2,
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const DEFAULT_THRESHOLD = 0.3;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Scan a text string for prompt injection patterns.
|
|
167
|
+
*
|
|
168
|
+
* @param text The text to scan
|
|
169
|
+
* @param threshold Risk score threshold (0–1) above which `flagged` is true
|
|
170
|
+
* @returns ScanResult with score, flagged boolean, and matches
|
|
171
|
+
*/
|
|
172
|
+
export function scanForInjection(
|
|
173
|
+
text: string,
|
|
174
|
+
threshold: number = DEFAULT_THRESHOLD,
|
|
175
|
+
): ScanResult {
|
|
176
|
+
if (!text) {
|
|
177
|
+
return { score: 0, flagged: false, matches: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const matches: InjectionMatch[] = [];
|
|
181
|
+
|
|
182
|
+
for (const def of PATTERNS) {
|
|
183
|
+
// Reset lastIndex for global regexes
|
|
184
|
+
def.regex.lastIndex = 0;
|
|
185
|
+
|
|
186
|
+
let m: RegExpExecArray | null;
|
|
187
|
+
while ((m = def.regex.exec(text)) !== null) {
|
|
188
|
+
matches.push({
|
|
189
|
+
pattern: def.pattern,
|
|
190
|
+
category: def.category,
|
|
191
|
+
matched: m[0],
|
|
192
|
+
position: m.index,
|
|
193
|
+
weight: def.weight,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const score = Math.min(
|
|
199
|
+
1,
|
|
200
|
+
matches.reduce((sum, m) => sum + m.weight, 0),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
score,
|
|
205
|
+
flagged: score >= threshold,
|
|
206
|
+
matches,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Sanitize text by wrapping it in safe delimiters and neutralising
|
|
212
|
+
* known injection patterns. Use this on content returned to the LLM
|
|
213
|
+
* when you want to keep the content but reduce injection risk.
|
|
214
|
+
*/
|
|
215
|
+
export function sanitizeContent(text: string): string {
|
|
216
|
+
if (!text) return text;
|
|
217
|
+
|
|
218
|
+
let out = text;
|
|
219
|
+
|
|
220
|
+
// Neutralise XML-like tags that mimic system boundaries
|
|
221
|
+
out = out.replace(/<\/?(system|assistant|user|tool_result|human|function_call|antml:)/gi, (m) =>
|
|
222
|
+
m.replace(/</g, "<").replace(/>/g, ">"),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Neutralise [INST] / [SYS] tags
|
|
226
|
+
out = out.replace(/\[(\/?)(?:INST|SYS)\]/gi, "[$1_$&_]");
|
|
227
|
+
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type OrgPolicy = {
|
|
5
|
+
id: string;
|
|
6
|
+
title?: string | null;
|
|
7
|
+
kind?: "predefined" | "custom" | null;
|
|
8
|
+
status?: "draft" | "active" | "disabled" | "archived" | null;
|
|
9
|
+
severity?: "info" | "warning" | "error" | "block" | null;
|
|
10
|
+
description: string;
|
|
11
|
+
priority: number;
|
|
12
|
+
scope: string;
|
|
13
|
+
enforce: boolean;
|
|
14
|
+
source: "org" | "local";
|
|
15
|
+
// Execution hints for generic evaluators (M2). Null for predefined
|
|
16
|
+
// rules that dispatch via the name-based validator registry.
|
|
17
|
+
type?: string | null;
|
|
18
|
+
config?: Record<string, unknown> | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse the simple YAML rules format used by Cortex:
|
|
23
|
+
*
|
|
24
|
+
* rules:
|
|
25
|
+
* - id: rule.foo
|
|
26
|
+
* description: "..."
|
|
27
|
+
* priority: 100
|
|
28
|
+
* enforce: true
|
|
29
|
+
*/
|
|
30
|
+
function parseRulesYaml(text: string, source: "org" | "local"): OrgPolicy[] {
|
|
31
|
+
const policies: OrgPolicy[] = [];
|
|
32
|
+
let current: Partial<OrgPolicy> | null = null;
|
|
33
|
+
|
|
34
|
+
for (const line of text.split("\n")) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
|
|
37
|
+
// New rule entry
|
|
38
|
+
if (trimmed.startsWith("- id:")) {
|
|
39
|
+
if (current?.id) {
|
|
40
|
+
policies.push(finalize(current, source));
|
|
41
|
+
}
|
|
42
|
+
current = { id: trimmed.slice(5).trim() };
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!current) continue;
|
|
47
|
+
|
|
48
|
+
if (trimmed.startsWith("title:")) {
|
|
49
|
+
current.title = trimmed.slice(6).trim().replace(/^["']|["']$/g, "");
|
|
50
|
+
} else if (trimmed.startsWith("kind:")) {
|
|
51
|
+
const kind = trimmed.slice(5).trim();
|
|
52
|
+
if (kind === "predefined" || kind === "custom") current.kind = kind;
|
|
53
|
+
} else if (trimmed.startsWith("status:")) {
|
|
54
|
+
const status = trimmed.slice(7).trim();
|
|
55
|
+
if (status === "draft" || status === "active" || status === "disabled" || status === "archived") {
|
|
56
|
+
current.status = status;
|
|
57
|
+
}
|
|
58
|
+
} else if (trimmed.startsWith("severity:")) {
|
|
59
|
+
const severity = trimmed.slice(9).trim();
|
|
60
|
+
if (severity === "info" || severity === "warning" || severity === "error" || severity === "block") {
|
|
61
|
+
current.severity = severity;
|
|
62
|
+
}
|
|
63
|
+
} else if (trimmed.startsWith("description:")) {
|
|
64
|
+
current.description = trimmed.slice(12).trim().replace(/^["']|["']$/g, "");
|
|
65
|
+
} else if (trimmed.startsWith("priority:")) {
|
|
66
|
+
current.priority = parseInt(trimmed.slice(9).trim(), 10) || 50;
|
|
67
|
+
} else if (trimmed.startsWith("scope:")) {
|
|
68
|
+
current.scope = trimmed.slice(6).trim();
|
|
69
|
+
} else if (trimmed.startsWith("enforce:")) {
|
|
70
|
+
current.enforce = trimmed.slice(8).trim() === "true";
|
|
71
|
+
} else if (trimmed.startsWith("type:")) {
|
|
72
|
+
const raw = trimmed.slice(5).trim();
|
|
73
|
+
current.type = raw === "null" || raw === "" ? null : raw;
|
|
74
|
+
} else if (trimmed.startsWith("config:")) {
|
|
75
|
+
// Config is JSON-encoded on a single line so the line-based parser
|
|
76
|
+
// doesn't need to understand nested YAML. `config: null` or an
|
|
77
|
+
// unparseable value leaves it null.
|
|
78
|
+
const raw = trimmed.slice(7).trim();
|
|
79
|
+
if (raw && raw !== "null") {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(raw);
|
|
82
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
83
|
+
current.config = parsed as Record<string, unknown>;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore malformed config; evaluator will see null and fail
|
|
87
|
+
// gracefully with its own validation message.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (current?.id) {
|
|
94
|
+
policies.push(finalize(current, source));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return policies;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function finalize(partial: Partial<OrgPolicy>, source: "org" | "local"): OrgPolicy {
|
|
101
|
+
return {
|
|
102
|
+
id: partial.id ?? "",
|
|
103
|
+
title: partial.title ?? partial.id ?? "",
|
|
104
|
+
kind: partial.kind ?? null,
|
|
105
|
+
status: partial.status ?? "active",
|
|
106
|
+
severity: partial.severity ?? "block",
|
|
107
|
+
description: partial.description ?? "",
|
|
108
|
+
priority: partial.priority ?? 50,
|
|
109
|
+
scope: partial.scope ?? "global",
|
|
110
|
+
enforce: partial.enforce ?? true,
|
|
111
|
+
source,
|
|
112
|
+
type: partial.type ?? null,
|
|
113
|
+
config: partial.config ?? null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function policiesToYaml(policies: OrgPolicy[]): string {
|
|
118
|
+
const lines = ["rules:"];
|
|
119
|
+
for (const p of policies) {
|
|
120
|
+
lines.push(` - id: ${p.id}`);
|
|
121
|
+
if (p.title) lines.push(` title: "${p.title.replace(/"/g, '\\"')}"`);
|
|
122
|
+
if (p.kind) lines.push(` kind: ${p.kind}`);
|
|
123
|
+
if (p.status) lines.push(` status: ${p.status}`);
|
|
124
|
+
if (p.severity) lines.push(` severity: ${p.severity}`);
|
|
125
|
+
lines.push(` description: "${p.description.replace(/"/g, '\\"')}"`);
|
|
126
|
+
lines.push(` priority: ${p.priority}`);
|
|
127
|
+
lines.push(` scope: ${p.scope}`);
|
|
128
|
+
lines.push(` enforce: ${p.enforce}`);
|
|
129
|
+
if (p.type) {
|
|
130
|
+
lines.push(` type: ${p.type}`);
|
|
131
|
+
}
|
|
132
|
+
if (p.config) {
|
|
133
|
+
lines.push(` config: ${JSON.stringify(p.config)}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push("");
|
|
136
|
+
}
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class PolicyStore {
|
|
141
|
+
private readonly contextDir: string;
|
|
142
|
+
private readonly orgRulesPath: string;
|
|
143
|
+
private readonly localRulesPath: string;
|
|
144
|
+
|
|
145
|
+
constructor(contextDir: string) {
|
|
146
|
+
this.contextDir = contextDir;
|
|
147
|
+
this.orgRulesPath = join(contextDir, "policies", "org-rules.yaml");
|
|
148
|
+
this.localRulesPath = join(contextDir, "rules.yaml");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
loadOrgPolicies(): OrgPolicy[] {
|
|
152
|
+
try {
|
|
153
|
+
const raw = readFileSync(this.orgRulesPath, "utf8");
|
|
154
|
+
return parseRulesYaml(raw, "org");
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
loadLocalPolicies(): OrgPolicy[] {
|
|
161
|
+
try {
|
|
162
|
+
const raw = readFileSync(this.localRulesPath, "utf8");
|
|
163
|
+
return parseRulesYaml(raw, "local");
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Merge org + local policies. Org rules override local rules with same ID.
|
|
171
|
+
* Result is sorted by priority (highest first).
|
|
172
|
+
*/
|
|
173
|
+
getMergedPolicies(): OrgPolicy[] {
|
|
174
|
+
const orgPolicies = this.loadOrgPolicies();
|
|
175
|
+
const localPolicies = this.loadLocalPolicies();
|
|
176
|
+
|
|
177
|
+
const merged = new Map<string, OrgPolicy>();
|
|
178
|
+
|
|
179
|
+
// Local first
|
|
180
|
+
for (const p of localPolicies) {
|
|
181
|
+
merged.set(p.id, p);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Org overrides
|
|
185
|
+
for (const p of orgPolicies) {
|
|
186
|
+
merged.set(p.id, p);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [...merged.values()].sort((a, b) => b.priority - a.priority);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
writeOrgPolicies(policies: OrgPolicy[]): void {
|
|
193
|
+
const dir = join(this.contextDir, "policies");
|
|
194
|
+
mkdirSync(dir, { recursive: true });
|
|
195
|
+
writeFileSync(this.orgRulesPath, policiesToYaml(policies));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type Role = "admin" | "developer" | "readonly";
|
|
2
|
+
|
|
3
|
+
export type RBACConfig = {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
default_role: Role;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const PERMISSIONS: Record<string, Role[]> = {
|
|
9
|
+
// Admin-only actions
|
|
10
|
+
"policy.write": ["admin"],
|
|
11
|
+
"policy.sync": ["admin"],
|
|
12
|
+
"telemetry.configure": ["admin"],
|
|
13
|
+
|
|
14
|
+
// Admin + developer
|
|
15
|
+
"audit.query": ["admin", "developer"],
|
|
16
|
+
"policy.list": ["admin", "developer"],
|
|
17
|
+
"telemetry.status": ["admin", "developer"],
|
|
18
|
+
"context.review": ["admin", "developer"],
|
|
19
|
+
"workflow.plan": ["admin", "developer"],
|
|
20
|
+
"workflow.review_plan": ["admin", "developer"],
|
|
21
|
+
"workflow.start": ["admin", "developer"],
|
|
22
|
+
"workflow.update": ["admin", "developer"],
|
|
23
|
+
"workflow.note": ["admin", "developer"],
|
|
24
|
+
"workflow.todo": ["admin", "developer"],
|
|
25
|
+
"workflow.approve": ["admin", "developer"],
|
|
26
|
+
|
|
27
|
+
// All roles
|
|
28
|
+
"enterprise.status": ["admin", "developer", "readonly"],
|
|
29
|
+
"workflow.status": ["admin", "developer", "readonly"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function checkAccess(role: Role, action: string): boolean {
|
|
33
|
+
const allowed = PERMISSIONS[action];
|
|
34
|
+
if (!allowed) return false; // deny unknown actions by default
|
|
35
|
+
return allowed.includes(role);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getAccessDeniedMessage(role: Role, action: string): string {
|
|
39
|
+
return `Access denied: role '${role}' cannot perform '${action}'`;
|
|
40
|
+
}
|