@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.
Files changed (79) hide show
  1. package/bin/cortex.mjs +679 -32
  2. package/bin/style.mjs +349 -0
  3. package/package.json +4 -3
  4. package/scaffold/mcp/package-lock.json +834 -671
  5. package/scaffold/mcp/package.json +1 -1
  6. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  7. package/scaffold/mcp/src/cli/govern.ts +987 -0
  8. package/scaffold/mcp/src/cli/run.ts +306 -0
  9. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  10. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  11. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  12. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  13. package/scaffold/mcp/src/core/config.ts +329 -0
  14. package/scaffold/mcp/src/core/index.ts +34 -0
  15. package/scaffold/mcp/src/core/license.ts +202 -0
  16. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  17. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  18. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  19. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  20. package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
  21. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  22. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  23. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  24. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  25. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  26. package/scaffold/mcp/src/daemon/client.ts +155 -0
  27. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  28. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  29. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  30. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  31. package/scaffold/mcp/src/daemon/main.ts +300 -0
  32. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  33. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  34. package/scaffold/mcp/src/daemon/server.ts +227 -0
  35. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  36. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  37. package/scaffold/mcp/src/embed.ts +1 -1
  38. package/scaffold/mcp/src/embeddings.ts +1 -1
  39. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  40. package/scaffold/mcp/src/enterprise/index.ts +415 -0
  41. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  42. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  43. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
  44. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  45. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
  46. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  47. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  48. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  49. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  50. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  51. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  52. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  53. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  54. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  55. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  56. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  57. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  58. package/scaffold/mcp/src/plugin.ts +150 -0
  59. package/scaffold/mcp/src/server.ts +218 -7
  60. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  61. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  62. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  63. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  64. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  65. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  66. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  67. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  68. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  69. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  70. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  71. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  72. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  73. package/scaffold/mcp/tests/run.test.mjs +109 -0
  74. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  75. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  76. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  77. package/scaffold/scripts/bootstrap.sh +0 -11
  78. package/scaffold/scripts/doctor.sh +24 -4
  79. 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, "&lt;").replace(/>/g, "&gt;"),
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
+ }