mergen-server 1.4.0

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 (308) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +96 -0
  3. package/dist/__stubs__/calibration.js +471 -0
  4. package/dist/__stubs__/causal.js +601 -0
  5. package/dist/__stubs__/closed-source.js +150 -0
  6. package/dist/__stubs__/license.js +35 -0
  7. package/dist/__stubs__/plans.js +197 -0
  8. package/dist/app.js +467 -0
  9. package/dist/cli.js +250 -0
  10. package/dist/commands/agent-identity.js +79 -0
  11. package/dist/commands/gate.js +107 -0
  12. package/dist/commands/github.js +632 -0
  13. package/dist/commands/incident.js +644 -0
  14. package/dist/commands/policy.js +710 -0
  15. package/dist/commands/scan.js +243 -0
  16. package/dist/commands/setup.js +1218 -0
  17. package/dist/commands/shared.js +109 -0
  18. package/dist/commands/team.js +809 -0
  19. package/dist/datadog/blame-attribution.js +228 -0
  20. package/dist/datadog/client.js +159 -0
  21. package/dist/datadog/compactor.js +169 -0
  22. package/dist/datadog/fingerprinter.js +34 -0
  23. package/dist/datadog/incident-state.js +21 -0
  24. package/dist/datadog/line-matcher.js +56 -0
  25. package/dist/datadog/memory-store.js +399 -0
  26. package/dist/datadog/otel-trace.js +67 -0
  27. package/dist/index.js +505 -0
  28. package/dist/intelligence/action-risk.js +132 -0
  29. package/dist/intelligence/activity-feed.js +40 -0
  30. package/dist/intelligence/agent-identity.js +115 -0
  31. package/dist/intelligence/agent-pipeline.js +329 -0
  32. package/dist/intelligence/agent-profiles.js +81 -0
  33. package/dist/intelligence/ai-commit.js +16 -0
  34. package/dist/intelligence/approval-analytics.js +42 -0
  35. package/dist/intelligence/approval-events.js +5 -0
  36. package/dist/intelligence/arch-boundaries.js +94 -0
  37. package/dist/intelligence/arch-graph.js +117 -0
  38. package/dist/intelligence/autonomy.js +397 -0
  39. package/dist/intelligence/azure-broker.js +57 -0
  40. package/dist/intelligence/azure-proxy-sessions.js +49 -0
  41. package/dist/intelligence/baseline.js +1 -0
  42. package/dist/intelligence/behavior-baseline.js +134 -0
  43. package/dist/intelligence/billing.js +1 -0
  44. package/dist/intelligence/blast-radius.js +224 -0
  45. package/dist/intelligence/bypass.js +157 -0
  46. package/dist/intelligence/calibration-classifier.js +77 -0
  47. package/dist/intelligence/calibration-git-sync.js +74 -0
  48. package/dist/intelligence/calibration.js +1 -0
  49. package/dist/intelligence/cascade-detector.js +58 -0
  50. package/dist/intelligence/case-study-generator.js +126 -0
  51. package/dist/intelligence/causal-graph.js +126 -0
  52. package/dist/intelligence/causal.js +1 -0
  53. package/dist/intelligence/change-risk.js +102 -0
  54. package/dist/intelligence/compliance-report.js +134 -0
  55. package/dist/intelligence/confidence-report.js +85 -0
  56. package/dist/intelligence/corpus-to-policy.js +140 -0
  57. package/dist/intelligence/credential-broker.js +112 -0
  58. package/dist/intelligence/credential-misuse.js +39 -0
  59. package/dist/intelligence/debug-sessions.js +108 -0
  60. package/dist/intelligence/decision-escalation.js +36 -0
  61. package/dist/intelligence/decision-memory.js +47 -0
  62. package/dist/intelligence/decision-similarity.js +85 -0
  63. package/dist/intelligence/default-safety-tests.js +53 -0
  64. package/dist/intelligence/degradation-watcher.js +108 -0
  65. package/dist/intelligence/detector-plugins.js +70 -0
  66. package/dist/intelligence/detectors.js +1 -0
  67. package/dist/intelligence/device-auth.js +138 -0
  68. package/dist/intelligence/diff-size.js +42 -0
  69. package/dist/intelligence/ebpf-verify.js +45 -0
  70. package/dist/intelligence/enterprise-policy-engine.js +1122 -0
  71. package/dist/intelligence/error-fingerprint.js +1 -0
  72. package/dist/intelligence/execution-gate.js +100 -0
  73. package/dist/intelligence/execution-mode.js +22 -0
  74. package/dist/intelligence/gate-analytics.js +256 -0
  75. package/dist/intelligence/gate-decision.js +64 -0
  76. package/dist/intelligence/gcp-broker.js +50 -0
  77. package/dist/intelligence/git-adr-sync.js +207 -0
  78. package/dist/intelligence/hitl-hold.js +460 -0
  79. package/dist/intelligence/hypothesis-history.js +93 -0
  80. package/dist/intelligence/idp-identity.js +83 -0
  81. package/dist/intelligence/impl-critic.js +216 -0
  82. package/dist/intelligence/incident-autopilot.js +622 -0
  83. package/dist/intelligence/incident-replay.js +162 -0
  84. package/dist/intelligence/incident-result-cache.js +33 -0
  85. package/dist/intelligence/incident-similarity.js +58 -0
  86. package/dist/intelligence/infra-detectors.js +312 -0
  87. package/dist/intelligence/license.js +185 -0
  88. package/dist/intelligence/llm-spokesperson.js +62 -0
  89. package/dist/intelligence/mcp-prompts.js +320 -0
  90. package/dist/intelligence/mcp-resources.js +171 -0
  91. package/dist/intelligence/normalize.js +108 -0
  92. package/dist/intelligence/notifications.js +83 -0
  93. package/dist/intelligence/oidc-issuer.js +87 -0
  94. package/dist/intelligence/override-corpus.js +597 -0
  95. package/dist/intelligence/planning-gate.js +84 -0
  96. package/dist/intelligence/plans.js +223 -0
  97. package/dist/intelligence/platt-scaling.js +164 -0
  98. package/dist/intelligence/policy-proposals.js +84 -0
  99. package/dist/intelligence/policy-suggester.js +161 -0
  100. package/dist/intelligence/policy-sync.js +103 -0
  101. package/dist/intelligence/policy-test-runner.js +35 -0
  102. package/dist/intelligence/postmortem-parser.js +181 -0
  103. package/dist/intelligence/postmortem-retrieval.js +224 -0
  104. package/dist/intelligence/postmortem-store.js +596 -0
  105. package/dist/intelligence/pr-commenter.js +81 -0
  106. package/dist/intelligence/pr-policy-gate.js +38 -0
  107. package/dist/intelligence/pr-shadow-analyzer.js +229 -0
  108. package/dist/intelligence/process-tree.js +108 -0
  109. package/dist/intelligence/prompts.js +1 -0
  110. package/dist/intelligence/replay.js +38 -0
  111. package/dist/intelligence/repro-steps.js +1 -0
  112. package/dist/intelligence/resource-extractors.js +0 -0
  113. package/dist/intelligence/resource-file-context.js +138 -0
  114. package/dist/intelligence/risk-score.js +22 -0
  115. package/dist/intelligence/rollback.js +137 -0
  116. package/dist/intelligence/runbook-updater.js +143 -0
  117. package/dist/intelligence/sandbox.js +194 -0
  118. package/dist/intelligence/script-trust.js +104 -0
  119. package/dist/intelligence/session-metrics.js +67 -0
  120. package/dist/intelligence/session-threat-tracker.js +231 -0
  121. package/dist/intelligence/shadow-digest-cron.js +81 -0
  122. package/dist/intelligence/shadow-log.js +284 -0
  123. package/dist/intelligence/shell-ast.js +145 -0
  124. package/dist/intelligence/siem-forward.js +104 -0
  125. package/dist/intelligence/slack-digest.js +151 -0
  126. package/dist/intelligence/slack-override-loop.js +217 -0
  127. package/dist/intelligence/slack-routing.js +67 -0
  128. package/dist/intelligence/slack.js +900 -0
  129. package/dist/intelligence/sql-ast.js +31 -0
  130. package/dist/intelligence/team.js +1 -0
  131. package/dist/intelligence/telemetry.js +76 -0
  132. package/dist/intelligence/threshold-optimizer.js +75 -0
  133. package/dist/intelligence/token-budget.js +66 -0
  134. package/dist/intelligence/tool-guard.js +708 -0
  135. package/dist/intelligence/tool-manifest 2.js +74 -0
  136. package/dist/intelligence/tool-manifest.js +125 -0
  137. package/dist/intelligence/tools-analysis.js +857 -0
  138. package/dist/intelligence/tools-arch.js +152 -0
  139. package/dist/intelligence/tools-autonomy.js +495 -0
  140. package/dist/intelligence/tools-blast-radius.js +140 -0
  141. package/dist/intelligence/tools-browser.js +431 -0
  142. package/dist/intelligence/tools-change-timeline.js +154 -0
  143. package/dist/intelligence/tools-credentials.js +117 -0
  144. package/dist/intelligence/tools-datadog.js +233 -0
  145. package/dist/intelligence/tools-debug-sessions.js +209 -0
  146. package/dist/intelligence/tools-discovery.js +284 -0
  147. package/dist/intelligence/tools-infra.js +612 -0
  148. package/dist/intelligence/tools-intent.js +128 -0
  149. package/dist/intelligence/tools-memory.js +514 -0
  150. package/dist/intelligence/tools-runbook.js +951 -0
  151. package/dist/intelligence/tools-sessions.js +107 -0
  152. package/dist/intelligence/tools-state.js +232 -0
  153. package/dist/intelligence/tools-utility.js +430 -0
  154. package/dist/intelligence/tools-validate.js +215 -0
  155. package/dist/intelligence/tools.js +1 -0
  156. package/dist/intelligence/trace-context.js +11 -0
  157. package/dist/intelligence/unclassified-clusters.js +87 -0
  158. package/dist/intelligence/usage.js +195 -0
  159. package/dist/routes/active-authors.js +63 -0
  160. package/dist/routes/activity-feed.js +21 -0
  161. package/dist/routes/adr.js +117 -0
  162. package/dist/routes/agent-activity.js +184 -0
  163. package/dist/routes/agent-blunders.js +163 -0
  164. package/dist/routes/agents.js +175 -0
  165. package/dist/routes/api-keys.js +65 -0
  166. package/dist/routes/arch.js +75 -0
  167. package/dist/routes/audit-export.js +93 -0
  168. package/dist/routes/billing-dashboard.js +158 -0
  169. package/dist/routes/billing-outcome.js +77 -0
  170. package/dist/routes/calibration.js +305 -0
  171. package/dist/routes/ci-gate-history.js +20 -0
  172. package/dist/routes/ci-gate.js +232 -0
  173. package/dist/routes/ci.js +293 -0
  174. package/dist/routes/compliance-report.js +24 -0
  175. package/dist/routes/confidence.js +30 -0
  176. package/dist/routes/credentials.js +119 -0
  177. package/dist/routes/dashboard.js +1407 -0
  178. package/dist/routes/decision-memory.js +42 -0
  179. package/dist/routes/demo.js +844 -0
  180. package/dist/routes/device-auth-stub.js +173 -0
  181. package/dist/routes/explain-why.js +39 -0
  182. package/dist/routes/gate-analytics.js +213 -0
  183. package/dist/routes/gate.js +28 -0
  184. package/dist/routes/github-webhook.js +364 -0
  185. package/dist/routes/habituation.js +30 -0
  186. package/dist/routes/health-integrations.js +243 -0
  187. package/dist/routes/heartbeats.js +45 -0
  188. package/dist/routes/hitl.js +364 -0
  189. package/dist/routes/impact-report.js +645 -0
  190. package/dist/routes/incident-webhook.js +63 -0
  191. package/dist/routes/incidents.js +366 -0
  192. package/dist/routes/layers.js +62 -0
  193. package/dist/routes/license.js +187 -0
  194. package/dist/routes/oidc-issuer.js +30 -0
  195. package/dist/routes/onboarding.js +170 -0
  196. package/dist/routes/otel.js +67 -0
  197. package/dist/routes/otlp-receiver.js +179 -0
  198. package/dist/routes/overrides.js +241 -0
  199. package/dist/routes/pagerduty.js +258 -0
  200. package/dist/routes/policies.js +809 -0
  201. package/dist/routes/policy-nl.js +169 -0
  202. package/dist/routes/postmortem.js +102 -0
  203. package/dist/routes/pr-shadow.js +47 -0
  204. package/dist/routes/rbac.js +61 -0
  205. package/dist/routes/replay.js +17 -0
  206. package/dist/routes/risk-report.js +164 -0
  207. package/dist/routes/runbooks.js +125 -0
  208. package/dist/routes/safety-test.js +174 -0
  209. package/dist/routes/sdk.js +222 -0
  210. package/dist/routes/sensor.js +819 -0
  211. package/dist/routes/sentry.js +140 -0
  212. package/dist/routes/sessions.js +43 -0
  213. package/dist/routes/setup-ui.js +594 -0
  214. package/dist/routes/shadow-report.js +107 -0
  215. package/dist/routes/slack-routing.js +171 -0
  216. package/dist/routes/team-usage.js +71 -0
  217. package/dist/routes/telemetry.js +28 -0
  218. package/dist/routes/tenants.js +199 -0
  219. package/dist/routes/tickets.js +167 -0
  220. package/dist/routes/validate.js +13 -0
  221. package/dist/routes/war-room.js +113 -0
  222. package/dist/scripts/check-arch.js +22 -0
  223. package/dist/seeds/community-corpus.js +176 -0
  224. package/dist/seeds/corpus.js +1224 -0
  225. package/dist/sensor/action-ledger.js +327 -0
  226. package/dist/sensor/adr-store.js +140 -0
  227. package/dist/sensor/agent-blunder-store.js +370 -0
  228. package/dist/sensor/agent-context-store.js +225 -0
  229. package/dist/sensor/agent-memory-store.js +231 -0
  230. package/dist/sensor/audit-fetch.js +23 -0
  231. package/dist/sensor/audit-log.js +273 -0
  232. package/dist/sensor/buffer-schemas.js +225 -0
  233. package/dist/sensor/buffer.js +599 -0
  234. package/dist/sensor/bypass-tracker.js +93 -0
  235. package/dist/sensor/ci-gate-history.js +70 -0
  236. package/dist/sensor/cloud-auth.js +183 -0
  237. package/dist/sensor/commit-context-store.js +235 -0
  238. package/dist/sensor/docker-log-stream.js +164 -0
  239. package/dist/sensor/docker-monitor.js +122 -0
  240. package/dist/sensor/extended-buffer.js +46 -0
  241. package/dist/sensor/feedback-token.js +41 -0
  242. package/dist/sensor/file-lock.js +6 -0
  243. package/dist/sensor/fs-watcher.js +106 -0
  244. package/dist/sensor/gate-heartbeat.js +122 -0
  245. package/dist/sensor/git-suspect.js +136 -0
  246. package/dist/sensor/habituation-store.js +77 -0
  247. package/dist/sensor/heartbeat-monitor.js +133 -0
  248. package/dist/sensor/incident-store.js +340 -0
  249. package/dist/sensor/infra-normalizer.js +513 -0
  250. package/dist/sensor/ingest.js +339 -0
  251. package/dist/sensor/jest-reporter.js +52 -0
  252. package/dist/sensor/k8s-events.js +132 -0
  253. package/dist/sensor/layer2-store.js +111 -0
  254. package/dist/sensor/layer3-store.js +230 -0
  255. package/dist/sensor/layer4-store.js +147 -0
  256. package/dist/sensor/logger.js +13 -0
  257. package/dist/sensor/otel-exporter.js +161 -0
  258. package/dist/sensor/paths.js +71 -0
  259. package/dist/sensor/policy-history.js +147 -0
  260. package/dist/sensor/pr-shadow-store.js +83 -0
  261. package/dist/sensor/process-watcher.js +162 -0
  262. package/dist/sensor/rbac.js +92 -0
  263. package/dist/sensor/redact.js +114 -0
  264. package/dist/sensor/redis-store.js +191 -0
  265. package/dist/sensor/route-reachability.js +64 -0
  266. package/dist/sensor/security-utils.js +18 -0
  267. package/dist/sensor/service-graph.js +170 -0
  268. package/dist/sensor/service-topology.js +227 -0
  269. package/dist/sensor/session-history.js +74 -0
  270. package/dist/sensor/session-persist.js +38 -0
  271. package/dist/sensor/shadow-promote.js +122 -0
  272. package/dist/sensor/sourcemap.js +281 -0
  273. package/dist/sensor/sqlite-store.js +205 -0
  274. package/dist/sensor/sso.js +164 -0
  275. package/dist/sensor/vitest-reporter.js +65 -0
  276. package/dist/sensor/watcher.js +48 -0
  277. package/dist/storage/interfaces.js +0 -0
  278. package/dist/storage/pg/pg-action-ledger.js +138 -0
  279. package/dist/storage/pg/pg-approval-store.js +107 -0
  280. package/dist/storage/pg/pg-blunder-store.js +193 -0
  281. package/dist/storage/pg/pg-ci-gate-history.js +83 -0
  282. package/dist/storage/pg/pg-client.js +24 -0
  283. package/dist/storage/pg/pg-event-store.js +63 -0
  284. package/dist/storage/pg/pg-incident-store.js +190 -0
  285. package/dist/storage/pg/pg-migrations.js +26 -0
  286. package/dist/storage/pg/pg-override-corpus.js +447 -0
  287. package/dist/storage/pg/pg-shadow-log.js +129 -0
  288. package/dist/storage/sqlite/sqlite-action-ledger.js +18 -0
  289. package/dist/storage/sqlite/sqlite-approval-store.js +37 -0
  290. package/dist/storage/sqlite/sqlite-blunder-store.js +27 -0
  291. package/dist/storage/sqlite/sqlite-ci-gate-history.js +19 -0
  292. package/dist/storage/sqlite/sqlite-event-store.js +29 -0
  293. package/dist/storage/sqlite/sqlite-incident-store.js +34 -0
  294. package/dist/storage/sqlite/sqlite-override-corpus.js +75 -0
  295. package/dist/storage/sqlite/sqlite-shadow-log.js +36 -0
  296. package/dist/storage/store-factory.js +55 -0
  297. package/dist/storage/store-registry.js +31 -0
  298. package/dist/update-checker.js +107 -0
  299. package/dist/workers/autopilot-worker.js +28 -0
  300. package/dist/workers/notification-worker.js +28 -0
  301. package/dist/workers/queues.js +58 -0
  302. package/dist/workers/validation-worker.js +24 -0
  303. package/dist/workers/worker-registry.js +18 -0
  304. package/package.json +123 -0
  305. package/sdk/mergen-inject.js +260 -0
  306. package/sdk/node.js +313 -0
  307. package/sdk/vite-plugin.ts +57 -0
  308. package/sdk/webpack-plugin.js +106 -0
@@ -0,0 +1,809 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import {
4
+ loadEnterprisePolicy,
5
+ saveEnterprisePolicy,
6
+ EnterprisePolicyConditionsSchema,
7
+ IMMUTABLE_RULE_IDS
8
+ } from "../intelligence/enterprise-policy-engine.js";
9
+ import { getRuleFirings, getGateEvents } from "../intelligence/gate-analytics.js";
10
+ import { evaluateEnterprisePolicy } from "../intelligence/enterprise-policy-engine.js";
11
+ import { computePolicySuggestions } from "../intelligence/policy-suggester.js";
12
+ import { getProposals, getProposal, markProposalDecided } from "../intelligence/policy-proposals.js";
13
+ import { activateProposedRule } from "../intelligence/corpus-to-policy.js";
14
+ import { recordActivity } from "../intelligence/activity-feed.js";
15
+ import { getPolicyHistory, listPolicyVersions, getPolicyVersion } from "../sensor/policy-history.js";
16
+ import { getStores } from "../storage/store-registry.js";
17
+ function createPoliciesRouter(localSecret = "") {
18
+ const router = Router();
19
+ router.get("/policies", (req, res) => {
20
+ const presentedSecret = req.headers["x-mergen-secret"] ?? (typeof req.query["secret"] === "string" ? req.query["secret"] : "");
21
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
22
+ res.setHeader("Cache-Control", "no-store");
23
+ res.send(buildPoliciesHtml(presentedSecret));
24
+ });
25
+ router.get("/policies/json", (_req, res) => {
26
+ const policy = loadEnterprisePolicy();
27
+ const firings = getRuleFirings();
28
+ res.json({
29
+ ok: true,
30
+ enabled: policy.enabled,
31
+ rules: policy.rules.map((r) => ({
32
+ ...r,
33
+ triggerCount: firings.get(r.id) ?? 0,
34
+ // Immutable rules are evaluated even when `enabled` is false and cannot
35
+ // be edited/deleted/replaced via this API — see IMMUTABLE_RULE_IDS.
36
+ immutable: IMMUTABLE_RULE_IDS.has(r.id)
37
+ }))
38
+ });
39
+ });
40
+ router.patch("/policies/enabled", (req, res) => {
41
+ const body = z.object({ enabled: z.boolean() }).safeParse(req.body);
42
+ if (!body.success) {
43
+ res.status(400).json({ error: "enabled (boolean) required" });
44
+ return;
45
+ }
46
+ const policy = loadEnterprisePolicy();
47
+ try {
48
+ saveEnterprisePolicy({ ...policy, enabled: body.data.enabled });
49
+ res.json({ ok: true, enabled: body.data.enabled });
50
+ } catch (err) {
51
+ res.status(500).json({ error: String(err) });
52
+ }
53
+ });
54
+ router.post("/policies/rules", (req, res) => {
55
+ const RuleSchema = z.object({
56
+ id: z.string().min(1).regex(/^[a-z0-9_-]+$/, "Rule ID must contain only lowercase letters, digits, underscores, or hyphens"),
57
+ name: z.string().min(1),
58
+ description: z.string(),
59
+ action: z.enum(["block", "warn", "pass"]),
60
+ reason: z.string(),
61
+ conditions: EnterprisePolicyConditionsSchema
62
+ });
63
+ const parsed = RuleSchema.safeParse(req.body);
64
+ if (!parsed.success) {
65
+ res.status(400).json({ error: parsed.error.issues });
66
+ return;
67
+ }
68
+ const policy = loadEnterprisePolicy();
69
+ if (policy.rules.some((r) => r.id === parsed.data.id)) {
70
+ res.status(409).json({ error: `Rule id '${parsed.data.id}' already exists` });
71
+ return;
72
+ }
73
+ try {
74
+ saveEnterprisePolicy({ ...policy, rules: [...policy.rules, parsed.data] });
75
+ res.status(201).json({ ok: true, rule: parsed.data });
76
+ } catch (err) {
77
+ res.status(500).json({ error: String(err) });
78
+ }
79
+ });
80
+ router.patch("/policies/rules/:id", (req, res) => {
81
+ const { id } = req.params;
82
+ const PatchSchema = z.object({
83
+ name: z.string().min(1).optional(),
84
+ description: z.string().optional(),
85
+ action: z.enum(["block", "warn", "pass"]).optional(),
86
+ reason: z.string().optional(),
87
+ conditions: EnterprisePolicyConditionsSchema.optional()
88
+ }).strict();
89
+ const parsed = PatchSchema.safeParse(req.body);
90
+ if (!parsed.success) {
91
+ res.status(400).json({ error: parsed.error.issues });
92
+ return;
93
+ }
94
+ if (IMMUTABLE_RULE_IDS.has(id)) {
95
+ res.status(403).json({ error: `Rule '${id}' is a hard-safety guardrail and cannot be modified via this API.` });
96
+ return;
97
+ }
98
+ const policy = loadEnterprisePolicy();
99
+ const idx = policy.rules.findIndex((r) => r.id === id);
100
+ if (idx === -1) {
101
+ res.status(404).json({ error: "Rule not found" });
102
+ return;
103
+ }
104
+ const updated = { ...policy.rules[idx], ...parsed.data, id };
105
+ const rules = [...policy.rules];
106
+ rules[idx] = updated;
107
+ try {
108
+ saveEnterprisePolicy({ ...policy, rules });
109
+ res.json({ ok: true, rule: updated });
110
+ } catch (err) {
111
+ res.status(500).json({ error: String(err) });
112
+ }
113
+ });
114
+ router.delete("/policies/rules/:id", (req, res) => {
115
+ const { id } = req.params;
116
+ if (IMMUTABLE_RULE_IDS.has(id)) {
117
+ res.status(403).json({ error: `Rule '${id}' is a hard-safety guardrail and cannot be deleted via this API.` });
118
+ return;
119
+ }
120
+ const policy = loadEnterprisePolicy();
121
+ if (!policy.rules.some((r) => r.id === id)) {
122
+ res.status(404).json({ error: "Rule not found" });
123
+ return;
124
+ }
125
+ try {
126
+ saveEnterprisePolicy({ ...policy, rules: policy.rules.filter((r) => r.id !== id) });
127
+ res.json({ ok: true });
128
+ } catch (err) {
129
+ res.status(500).json({ error: String(err) });
130
+ }
131
+ });
132
+ router.get("/policies/history", (_req, res) => {
133
+ const history = getPolicyHistory();
134
+ res.json({
135
+ ok: true,
136
+ count: history.length,
137
+ history
138
+ });
139
+ });
140
+ router.get("/policies/versions", (_req, res) => {
141
+ res.json({ ok: true, versions: listPolicyVersions() });
142
+ });
143
+ router.post("/policies/versions/:id/revert", (req, res) => {
144
+ const snapshot = getPolicyVersion(req.params.id);
145
+ if (!snapshot) {
146
+ res.status(404).json({ error: `Policy version '${req.params.id}' not found` });
147
+ return;
148
+ }
149
+ const local = loadEnterprisePolicy();
150
+ const immutableRules = local.rules.filter((r) => IMMUTABLE_RULE_IDS.has(r.id));
151
+ const snapshotWithoutImmutable = snapshot.rules.filter((r) => !IMMUTABLE_RULE_IDS.has(r.id));
152
+ const restored = { ...snapshot, rules: [...immutableRules, ...snapshotWithoutImmutable] };
153
+ try {
154
+ saveEnterprisePolicy(restored, `revert:${req.params.id}`);
155
+ res.json({ ok: true, revertedTo: req.params.id, ruleCount: restored.rules.length });
156
+ } catch (err) {
157
+ res.status(400).json({ error: String(err) });
158
+ }
159
+ });
160
+ router.post("/policies/diff-impact", (req, res) => {
161
+ const PolicySchema = z.object({
162
+ enabled: z.boolean().default(true),
163
+ rules: z.array(z.object({
164
+ id: z.string().default("__diff__"),
165
+ name: z.string().default("Diff candidate"),
166
+ description: z.string().default(""),
167
+ action: z.enum(["block", "warn", "pass"]).default("block"),
168
+ reason: z.string().default(""),
169
+ conditions: EnterprisePolicyConditionsSchema
170
+ }))
171
+ });
172
+ const parsed = PolicySchema.safeParse(req.body);
173
+ if (!parsed.success) {
174
+ res.status(400).json({ error: parsed.error.issues });
175
+ return;
176
+ }
177
+ const candidate = parsed.data;
178
+ const current = loadEnterprisePolicy();
179
+ const events = getGateEvents();
180
+ let newlyBlocked = 0, newlyHeld = 0, noLongerBlocked = 0, noLongerHeld = 0, unchanged = 0;
181
+ const byToolFamily = /* @__PURE__ */ new Map();
182
+ const changedSamples = [];
183
+ for (const ev of events) {
184
+ const input = {
185
+ files: [ev.toolName],
186
+ commands: [ev.toolName, ev.command ?? ""].filter(Boolean),
187
+ actor: ev.actor,
188
+ service: ev.service,
189
+ timestamp: ev.ts,
190
+ environment: ev.environment ?? void 0,
191
+ agentId: ev.agentId ?? void 0
192
+ };
193
+ const before = evaluateEnterprisePolicy(input, current).verdict;
194
+ const after = evaluateEnterprisePolicy(input, candidate).verdict;
195
+ if (before === after) {
196
+ unchanged++;
197
+ continue;
198
+ }
199
+ if (after === "block" && before !== "block") newlyBlocked++;
200
+ if (after === "warn" && before !== "warn") newlyHeld++;
201
+ if (before === "block" && after !== "block") noLongerBlocked++;
202
+ if (before === "warn" && after !== "warn") noLongerHeld++;
203
+ byToolFamily.set(ev.toolName, (byToolFamily.get(ev.toolName) ?? 0) + 1);
204
+ if (changedSamples.length < 10) {
205
+ changedSamples.push({ ts: ev.ts, toolName: ev.toolName, command: ev.command, before, after });
206
+ }
207
+ }
208
+ res.json({
209
+ ok: true,
210
+ eventsInWindow: events.length,
211
+ newlyBlocked,
212
+ newlyHeld,
213
+ noLongerBlocked,
214
+ noLongerHeld,
215
+ unchanged,
216
+ byToolFamily: Object.fromEntries(byToolFamily),
217
+ changedSamples,
218
+ note: events.length === 0 ? "No gate events in memory yet \u2014 start using MCP tools to populate the window." : `Replayed ${events.length} recent gate events under both the current live policy and the candidate.`
219
+ });
220
+ });
221
+ router.get("/policies/dry-run-report", async (req, res) => {
222
+ const windowDays = Math.min(90, Math.max(1, Number(req.query.windowDays ?? 7)));
223
+ const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1e3;
224
+ const all = await getStores().blunders.list();
225
+ const dryRunEntries = all.filter((b) => b.tag === "policy_dry_run" && b.recordedAt >= cutoff);
226
+ const byRule = /* @__PURE__ */ new Map();
227
+ for (const b of dryRunEntries) {
228
+ for (const ruleId of b.triggeredRules ?? []) {
229
+ byRule.set(ruleId, (byRule.get(ruleId) ?? 0) + 1);
230
+ }
231
+ }
232
+ res.json({
233
+ ok: true,
234
+ windowDays,
235
+ totalWouldHaveActed: dryRunEntries.length,
236
+ byRule: Object.fromEntries(byRule),
237
+ examples: dryRunEntries.slice(-10).map((b) => ({
238
+ recordedAt: b.recordedAt,
239
+ command: b.command,
240
+ service: b.service,
241
+ reason: b.blockReason,
242
+ triggeredRules: b.triggeredRules
243
+ })),
244
+ note: dryRunEntries.length === 0 ? `No dry-run activity in the last ${windowDays} days. Set MERGEN_POLICY_DRY_RUN=true to start recording what the current policy would enforce.` : `${dryRunEntries.length} call(s) would have been blocked/held in the last ${windowDays} days under dry-run mode.`
245
+ });
246
+ });
247
+ router.get("/policies/shadow-promote/stats", async (_req, res) => {
248
+ const { getShadowRuleStats } = await import("../sensor/shadow-promote.js");
249
+ const stats = getShadowRuleStats();
250
+ const WINDOW_MS = 14 * 24 * 60 * 60 * 1e3;
251
+ const MIN_HITS = 5;
252
+ const enriched = stats.map((s) => ({
253
+ ...s,
254
+ eligible: s.totalHits >= MIN_HITS && s.falsePositives === 0 && Date.now() - s.firstHitAt >= WINDOW_MS,
255
+ daysUntilEligible: Math.max(0, Math.ceil((s.firstHitAt + WINDOW_MS - Date.now()) / 864e5)),
256
+ hitsUntilEligible: Math.max(0, MIN_HITS - s.totalHits)
257
+ }));
258
+ res.json({ ok: true, count: enriched.length, stats: enriched });
259
+ });
260
+ router.post("/policies/shadow-promote/feedback", async (req, res) => {
261
+ const schema = z.object({
262
+ ruleId: z.string().min(1),
263
+ ruleName: z.string().min(1),
264
+ isFalsePositive: z.boolean()
265
+ });
266
+ const parsed = schema.safeParse(req.body);
267
+ if (!parsed.success) {
268
+ res.status(400).json({ ok: false, error: "Invalid body", issues: parsed.error.issues });
269
+ return;
270
+ }
271
+ const { recordShadowRuleFeedback } = await import("../sensor/shadow-promote.js");
272
+ recordShadowRuleFeedback(parsed.data.ruleId, parsed.data.ruleName, parsed.data.isFalsePositive);
273
+ res.json({ ok: true, recorded: true, ruleId: parsed.data.ruleId, isFalsePositive: parsed.data.isFalsePositive });
274
+ });
275
+ router.delete("/policies/shadow-promote/stats/:ruleId", async (req, res) => {
276
+ const { resetShadowRuleStats } = await import("../sensor/shadow-promote.js");
277
+ resetShadowRuleStats(req.params.ruleId);
278
+ res.json({ ok: true, reset: true, ruleId: req.params.ruleId });
279
+ });
280
+ router.get("/policies/export", (_req, res) => {
281
+ const policy = loadEnterprisePolicy();
282
+ res.setHeader("Content-Type", "application/json");
283
+ res.setHeader("Cache-Control", "no-store");
284
+ res.json(policy);
285
+ });
286
+ router.post("/policies/import", (req, res) => {
287
+ const ImportRuleSchema = z.object({
288
+ id: z.string().min(1).regex(/^[a-z0-9_-]+$/),
289
+ name: z.string(),
290
+ description: z.string(),
291
+ action: z.enum(["block", "warn", "pass"]),
292
+ reason: z.string(),
293
+ conditions: EnterprisePolicyConditionsSchema
294
+ });
295
+ const body = z.object({
296
+ policy: z.object({ enabled: z.boolean(), rules: z.array(ImportRuleSchema) }),
297
+ mode: z.enum(["replace", "merge"]).optional().default("replace")
298
+ }).safeParse(req.body);
299
+ if (!body.success) {
300
+ res.status(400).json({ error: body.error.issues });
301
+ return;
302
+ }
303
+ const incoming = body.data.policy;
304
+ const mode = body.data.mode;
305
+ let merged;
306
+ if (mode === "merge") {
307
+ const local = loadEnterprisePolicy();
308
+ const existingIds = new Set(local.rules.map((r) => r.id));
309
+ const newRules = incoming.rules.filter((r) => !existingIds.has(r.id));
310
+ merged = { ...local, rules: [...local.rules, ...newRules] };
311
+ } else {
312
+ const local = loadEnterprisePolicy();
313
+ const immutableRules = local.rules.filter((r) => IMMUTABLE_RULE_IDS.has(r.id));
314
+ const incomingWithoutImmutable = incoming.rules.filter((r) => !IMMUTABLE_RULE_IDS.has(r.id));
315
+ merged = { ...incoming, rules: [...immutableRules, ...incomingWithoutImmutable] };
316
+ }
317
+ try {
318
+ saveEnterprisePolicy(merged);
319
+ res.json({ ok: true, ruleCount: merged.rules.length, mode });
320
+ } catch (err) {
321
+ res.status(400).json({ error: String(err) });
322
+ }
323
+ });
324
+ router.get("/policy-suggestions", async (_req, res) => {
325
+ const suggestions = await computePolicySuggestions();
326
+ const uncovered = suggestions.filter((s) => !s.alreadyCovered);
327
+ const proposals = getProposals("proposed");
328
+ res.json({
329
+ ok: true,
330
+ total: suggestions.length,
331
+ uncoveredCount: uncovered.length,
332
+ suggestions,
333
+ proposals,
334
+ pendingProposalCount: proposals.length,
335
+ note: uncovered.length > 0 ? `${uncovered.length} pattern${uncovered.length !== 1 ? "s" : ""} blocked repeatedly without a named policy rule. POST /policies/rules to formalise them.` : "All frequent blunder patterns are already covered by named rules."
336
+ });
337
+ });
338
+ router.get("/policies/proposals", (_req, res) => {
339
+ res.json({ ok: true, proposals: getProposals("proposed") });
340
+ });
341
+ router.post("/policies/proposals/:id/approve", (req, res) => {
342
+ const proposal = getProposal(req.params.id);
343
+ if (!proposal) {
344
+ res.status(404).json({ error: "proposal not found" });
345
+ return;
346
+ }
347
+ if (proposal.status !== "proposed") {
348
+ res.status(409).json({ error: `proposal already ${proposal.status}` });
349
+ return;
350
+ }
351
+ try {
352
+ if (proposal.rule.action !== "warn") {
353
+ res.status(422).json({ error: "proposal is not HOLD-only \u2014 refusing to activate" });
354
+ return;
355
+ }
356
+ activateProposedRule(proposal.rule);
357
+ markProposalDecided(proposal.id, "approved");
358
+ recordActivity({
359
+ toolName: "policy-proposal",
360
+ commandArg: proposal.rule.id,
361
+ verdict: "HOLD",
362
+ triggeredRules: [proposal.rule.id],
363
+ ruleNames: [proposal.rule.name]
364
+ });
365
+ res.json({ ok: true, activated: proposal.rule.id });
366
+ } catch (err) {
367
+ res.status(500).json({ error: String(err) });
368
+ }
369
+ });
370
+ router.post("/policies/proposals/:id/reject", (req, res) => {
371
+ const decided = markProposalDecided(req.params.id, "rejected");
372
+ if (!decided) {
373
+ res.status(404).json({ error: "proposal not found or already decided" });
374
+ return;
375
+ }
376
+ res.json({ ok: true, rejected: decided.id });
377
+ });
378
+ router.post("/policies/simulate", (req, res) => {
379
+ const RuleSchema = z.object({
380
+ id: z.string().default("__simulate__"),
381
+ name: z.string().default("Simulation"),
382
+ description: z.string().default(""),
383
+ action: z.enum(["block", "warn", "pass"]).default("block"),
384
+ reason: z.string().default("Simulated block"),
385
+ conditions: EnterprisePolicyConditionsSchema
386
+ });
387
+ const parsed = RuleSchema.safeParse(req.body);
388
+ if (!parsed.success) {
389
+ res.status(400).json({ error: parsed.error.issues });
390
+ return;
391
+ }
392
+ const rule = parsed.data;
393
+ const events = getGateEvents();
394
+ let wouldBlock = 0;
395
+ let wouldHold = 0;
396
+ let wouldPass = 0;
397
+ const examples = [];
398
+ const simulationPolicy = { enabled: true, rules: [rule] };
399
+ for (const ev of events) {
400
+ const result = evaluateEnterprisePolicy({
401
+ files: [ev.toolName],
402
+ commands: [ev.toolName, ev.command ?? ""].filter(Boolean),
403
+ actor: ev.actor,
404
+ service: ev.service,
405
+ timestamp: ev.ts,
406
+ environment: ev.environment ?? void 0,
407
+ agentId: ev.agentId ?? void 0
408
+ }, simulationPolicy);
409
+ if (result.verdict === "pass") {
410
+ wouldPass++;
411
+ } else if (result.verdict === "block") {
412
+ wouldBlock++;
413
+ if (examples.length < 5) examples.push({ ts: ev.ts, toolName: ev.toolName, command: ev.command, verdict: "block" });
414
+ } else {
415
+ wouldHold++;
416
+ if (examples.length < 5) examples.push({ ts: ev.ts, toolName: ev.toolName, command: ev.command, verdict: "hold" });
417
+ }
418
+ }
419
+ const total = events.length;
420
+ res.json({
421
+ ok: true,
422
+ rule: { id: rule.id, name: rule.name, action: rule.action },
423
+ eventsInWindow: total,
424
+ wouldBlock,
425
+ wouldHold,
426
+ wouldPass,
427
+ blockRate: total > 0 ? Math.round(wouldBlock / total * 100) : 0,
428
+ holdRate: total > 0 ? Math.round(wouldHold / total * 100) : 0,
429
+ examples,
430
+ note: total === 0 ? "No gate events in memory yet \u2014 start using MCP tools to populate the window." : `Simulated against ${total} recent gate events (rolling window, capped at 500).`
431
+ });
432
+ });
433
+ router.get("/policies/rules/:id/simulate", (req, res) => {
434
+ const policy = loadEnterprisePolicy();
435
+ const rule = policy.rules.find((r) => r.id === req.params.id);
436
+ if (!rule) {
437
+ res.status(404).json({ error: `Rule '${req.params.id}' not found` });
438
+ return;
439
+ }
440
+ const events = getGateEvents();
441
+ const simulationPolicy = { enabled: true, rules: [rule] };
442
+ let wouldBlock = 0, wouldHold = 0, wouldPass = 0;
443
+ const examples = [];
444
+ for (const ev of events) {
445
+ const result = evaluateEnterprisePolicy({
446
+ files: [ev.toolName],
447
+ commands: [ev.toolName, ev.command ?? ""].filter(Boolean),
448
+ actor: ev.actor,
449
+ service: ev.service,
450
+ timestamp: ev.ts,
451
+ environment: ev.environment ?? void 0,
452
+ agentId: ev.agentId ?? void 0
453
+ }, simulationPolicy);
454
+ if (result.verdict === "pass") {
455
+ wouldPass++;
456
+ } else if (result.verdict === "block") {
457
+ wouldBlock++;
458
+ if (examples.length < 5) examples.push({ ts: ev.ts, toolName: ev.toolName, command: ev.command, verdict: "block" });
459
+ } else {
460
+ wouldHold++;
461
+ if (examples.length < 5) examples.push({ ts: ev.ts, toolName: ev.toolName, command: ev.command, verdict: "hold" });
462
+ }
463
+ }
464
+ const total = events.length;
465
+ res.json({
466
+ ok: true,
467
+ rule: { id: rule.id, name: rule.name, action: rule.action, triggerCount: getRuleFirings().get(rule.id) ?? 0 },
468
+ eventsInWindow: total,
469
+ wouldBlock,
470
+ wouldHold,
471
+ wouldPass,
472
+ blockRate: total > 0 ? Math.round(wouldBlock / total * 100) : 0,
473
+ examples
474
+ });
475
+ });
476
+ router.post("/policies/preview", async (req, res) => {
477
+ const PolicySchema = z.object({
478
+ enabled: z.boolean().default(true),
479
+ rules: z.array(z.object({
480
+ id: z.string().default("__preview__"),
481
+ name: z.string().default("Preview"),
482
+ description: z.string().default(""),
483
+ action: z.enum(["block", "warn", "pass"]).default("block"),
484
+ reason: z.string().default("Preview block"),
485
+ conditions: EnterprisePolicyConditionsSchema
486
+ }))
487
+ });
488
+ const parsed = PolicySchema.safeParse(req.body);
489
+ if (!parsed.success) {
490
+ res.status(400).json({ error: parsed.error.issues });
491
+ return;
492
+ }
493
+ const previewPolicy = parsed.data;
494
+ const windowDays = Math.min(90, Math.max(1, Number(req.query.windowDays ?? 30)));
495
+ const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1e3;
496
+ const store = getStores().blunders;
497
+ const blunders = await store.list();
498
+ const inWindow = blunders.filter((b) => b.recordedAt >= cutoff);
499
+ let wouldBlock = 0, wouldWarn = 0, wouldPass = 0;
500
+ const changedVerdicts = [];
501
+ const agentsAffected = /* @__PURE__ */ new Set();
502
+ const servicesAffected = /* @__PURE__ */ new Set();
503
+ for (const b of inWindow) {
504
+ const result = evaluateEnterprisePolicy({
505
+ files: [],
506
+ commands: [b.command ?? b.blockReason].filter(Boolean),
507
+ actor: b.actor ?? "unknown",
508
+ service: b.service ?? "unknown",
509
+ timestamp: b.recordedAt
510
+ }, previewPolicy);
511
+ if (result.verdict === "block") wouldBlock++;
512
+ else if (result.verdict === "warn") wouldWarn++;
513
+ else wouldPass++;
514
+ if (result.verdict !== "block") {
515
+ if (b.agentId) agentsAffected.add(b.agentId);
516
+ if (b.service) servicesAffected.add(b.service);
517
+ if (changedVerdicts.length < 10) {
518
+ changedVerdicts.push({
519
+ id: b.id,
520
+ command: b.command,
521
+ original: "block",
522
+ preview: result.verdict,
523
+ service: b.service
524
+ });
525
+ }
526
+ }
527
+ }
528
+ const total = inWindow.length;
529
+ res.json({
530
+ ok: true,
531
+ windowDays,
532
+ blundersEvaluated: total,
533
+ wouldBlock,
534
+ wouldWarn,
535
+ wouldPass,
536
+ blockRate: total > 0 ? Math.round(wouldBlock / total * 100) : 0,
537
+ warnRate: total > 0 ? Math.round(wouldWarn / total * 100) : 0,
538
+ passRate: total > 0 ? Math.round(wouldPass / total * 100) : 0,
539
+ changedVerdicts,
540
+ agentsAffected: [...agentsAffected],
541
+ servicesAffected: [...servicesAffected],
542
+ note: total === 0 ? `No blunders in the last ${windowDays} days to evaluate against.` : `Replayed ${total} blunder log entries from the last ${windowDays} days against the preview policy.`
543
+ });
544
+ });
545
+ router.post("/policies/counterfactual", async (req, res) => {
546
+ const body = z.object({
547
+ policy: z.object({
548
+ enabled: z.boolean().default(true),
549
+ rules: z.array(z.any())
550
+ }),
551
+ limit: z.number().int().min(1).max(500).default(100)
552
+ }).safeParse(req.body);
553
+ if (!body.success) {
554
+ res.status(400).json({ error: body.error.issues });
555
+ return;
556
+ }
557
+ const { policy: counterPolicy, limit } = body.data;
558
+ const store = getStores().blunders;
559
+ const blunders = (await store.list()).slice(-limit);
560
+ const current = loadEnterprisePolicy();
561
+ const changed = [];
562
+ const unchanged = [];
563
+ for (const b of blunders) {
564
+ const input = {
565
+ files: [],
566
+ commands: [b.command ?? b.blockReason].filter(Boolean),
567
+ actor: b.actor ?? "unknown",
568
+ service: b.service ?? "unknown",
569
+ timestamp: b.recordedAt
570
+ };
571
+ const currentResult = evaluateEnterprisePolicy(input, current);
572
+ const cfResult = evaluateEnterprisePolicy(input, counterPolicy);
573
+ const row = {
574
+ id: b.id,
575
+ command: b.command,
576
+ service: b.service,
577
+ recordedAt: b.recordedAt,
578
+ currentVerdict: currentResult.verdict,
579
+ counterfactualVerdict: cfResult.verdict,
580
+ currentRules: currentResult.triggeredRules,
581
+ counterfactualRules: cfResult.triggeredRules
582
+ };
583
+ if (currentResult.verdict !== cfResult.verdict) changed.push(row);
584
+ else unchanged.push(row);
585
+ }
586
+ res.json({
587
+ ok: true,
588
+ evaluated: blunders.length,
589
+ changedCount: changed.length,
590
+ unchangedCount: unchanged.length,
591
+ changed: changed.slice(0, 50),
592
+ summary: {
593
+ wouldNowBlock: changed.filter((r) => r.counterfactualVerdict === "block").length,
594
+ wouldNowHold: changed.filter((r) => r.counterfactualVerdict === "warn").length,
595
+ wouldNowPass: changed.filter((r) => r.counterfactualVerdict === "pass").length
596
+ }
597
+ });
598
+ });
599
+ return router;
600
+ }
601
+ function buildPoliciesHtml(localSecret) {
602
+ const escapedSecret = JSON.stringify(localSecret);
603
+ return `<!DOCTYPE html>
604
+ <html lang="en">
605
+ <head>
606
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
607
+ <title>Policy Editor \xB7 Mergen</title>
608
+ <style>
609
+ :root{--bg:#0f1117;--surface:#1a1d26;--border:#2a2d3a;--text:#e2e8f0;--muted:#64748b;
610
+ --green:#22c55e;--yellow:#f59e0b;--red:#ef4444;--blue:#3b82f6;--purple:#a78bfa;}
611
+ *{box-sizing:border-box;margin:0;padding:0}
612
+ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;font-size:13px;line-height:1.6;padding:24px;max-width:1100px;margin:0 auto}
613
+ h1{font-size:18px;font-weight:700;margin-bottom:4px}
614
+ .subtitle{color:var(--muted);font-size:12px;margin-bottom:24px}
615
+ .nav{display:flex;gap:16px;margin-bottom:24px;font-size:12px}
616
+ .nav a{color:var(--blue);text-decoration:none}
617
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:20px;margin-bottom:20px}
618
+ .card-title{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;display:flex;align-items:center;gap:8px}
619
+ table{width:100%;border-collapse:collapse}
620
+ th{text-align:left;font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);padding:0 8px 8px;border-bottom:1px solid var(--border)}
621
+ td{padding:10px 8px;border-bottom:1px solid rgba(42,45,58,.6);vertical-align:middle;font-size:12px}
622
+ tr:last-child td{border-bottom:none}
623
+ .badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
624
+ .badge-block{background:rgba(239,68,68,.15);color:var(--red)}
625
+ .badge-warn{background:rgba(245,158,11,.15);color:var(--yellow)}
626
+ .badge-pass{background:rgba(34,197,94,.15);color:var(--green)}
627
+ .count{font-size:11px;font-weight:700;color:var(--purple);background:rgba(167,139,250,.12);padding:2px 6px;border-radius:6px}
628
+ input,select,textarea{background:#0d0f18;border:1px solid var(--border);color:var(--text);border-radius:4px;padding:4px 8px;font-size:12px;font-family:inherit;width:100%}
629
+ input:focus,select:focus,textarea:focus{outline:none;border-color:var(--blue)}
630
+ textarea{resize:vertical;min-height:60px}
631
+ .btn{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border:none;border-radius:4px;font-size:11px;font-weight:600;cursor:pointer;transition:.15s}
632
+ .btn-save{background:rgba(59,130,246,.2);color:var(--blue)}
633
+ .btn-save:hover{background:rgba(59,130,246,.35)}
634
+ .btn-delete{background:rgba(239,68,68,.15);color:var(--red)}
635
+ .btn-delete:hover{background:rgba(239,68,68,.3)}
636
+ .btn-add{background:rgba(34,197,94,.15);color:var(--green);padding:6px 14px;font-size:12px}
637
+ .btn-add:hover{background:rgba(34,197,94,.25)}
638
+ .toggle{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:12px}
639
+ .toggle input{width:auto}
640
+ .flash{position:fixed;bottom:20px;right:20px;padding:10px 16px;border-radius:6px;font-size:12px;font-weight:600;z-index:999;opacity:0;transition:.3s}
641
+ .flash.show{opacity:1}
642
+ .flash.ok{background:#1a3a2a;border:1px solid var(--green);color:var(--green)}
643
+ .flash.err{background:#3a1a1a;border:1px solid var(--red);color:var(--red)}
644
+ .readonly-rule{display:flex;align-items:flex-start;gap:10px;padding:8px 0;border-bottom:1px solid rgba(42,45,58,.6)}
645
+ .readonly-rule:last-child{border-bottom:none}
646
+ .readonly-rule .name{font-weight:600;font-size:12px;min-width:220px}
647
+ .readonly-rule .desc{color:var(--muted);font-size:11px}
648
+ .section-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 10px}
649
+ .form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px}
650
+ .form-row.single{grid-template-columns:1fr}
651
+ .form-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px}
652
+ </style>
653
+ </head>
654
+ <body>
655
+ <div class="nav"><a href="/dashboard">\u2190 Dashboard</a> <a href="/policies">Policy Editor</a></div>
656
+ <h1>Policy Editor</h1>
657
+ <p class="subtitle">Manage the rules that govern AI agent tool calls. Changes take effect immediately.</p>
658
+
659
+ <div id="flash" class="flash"></div>
660
+
661
+ <div class="card" id="toggle-card">
662
+ <div class="card-title">Policy Status</div>
663
+ <label class="toggle">
664
+ <input type="checkbox" id="policy-enabled" onchange="toggleEnabled(this.checked)">
665
+ <span id="enabled-label">Loading\u2026</span>
666
+ </label>
667
+ </div>
668
+
669
+ <div class="card">
670
+ <div class="card-title">Enterprise Rules <span id="rule-count" style="font-weight:400;color:var(--muted);text-transform:none;letter-spacing:0;font-size:11px"></span></div>
671
+ <table>
672
+ <thead><tr>
673
+ <th style="width:220px">Rule</th>
674
+ <th style="width:80px">Action</th>
675
+ <th style="width:60px">Triggers</th>
676
+ <th>Description</th>
677
+ <th style="width:120px"></th>
678
+ </tr></thead>
679
+ <tbody id="rules-body"><tr><td colspan="5" style="color:var(--muted);text-align:center;padding:20px">Loading\u2026</td></tr></tbody>
680
+ </table>
681
+ </div>
682
+
683
+ <div class="card">
684
+ <div class="card-title">Add New Rule</div>
685
+ <div class="form-row">
686
+ <div><div class="form-label">Rule ID</div><input id="new-id" placeholder="e.g. block_prod_deletes" /></div>
687
+ <div><div class="form-label">Name</div><input id="new-name" placeholder="Human-readable name" /></div>
688
+ </div>
689
+ <div class="form-row single"><div class="form-label">Description</div><input id="new-desc" placeholder="What this rule does and why" /></div>
690
+ <div class="form-row">
691
+ <div><div class="form-label">Action</div>
692
+ <select id="new-action"><option value="block">block</option><option value="warn">warn (HITL)</option><option value="pass">pass</option></select>
693
+ </div>
694
+ <div><div class="form-label">Reason (shown in block message)</div><input id="new-reason" placeholder="Why this call is restricted" /></div>
695
+ </div>
696
+ <div class="form-row single"><div class="form-label">Command patterns (comma-separated)</div><textarea id="new-commands" placeholder="terraform destroy, kubectl delete, drop table"></textarea></div>
697
+ <div class="form-row single"><div class="form-label">File patterns (comma-separated, optional)</div><input id="new-files" placeholder="auth, login, migration" /></div>
698
+ <div class="form-row">
699
+ <div><div class="form-label">Actor type</div>
700
+ <select id="new-actor"><option value="">any</option><option value="ai">ai only</option><option value="human">human only</option></select>
701
+ </div>
702
+ <div></div>
703
+ </div>
704
+ <button class="btn btn-add" onclick="addRule()">+ Add Rule</button>
705
+ </div>
706
+
707
+ <div class="card">
708
+ <div class="card-title">Hard Safety Rules <span style="font-weight:400;color:var(--muted);text-transform:none;letter-spacing:0;font-size:11px">(immutable \u2014 cannot be disabled, edited, or deleted via this API or the Policy Status toggle above)</span></div>
709
+ <div id="hard-rules">
710
+ <div class="readonly-rule"><div class="name">Blocked keywords</div><div class="desc">rm -rf, drop table, terraform destroy, kubectl delete, truncate, format c:, destroy, nuke, wipe \u2014 always evaluated, including while Policy Status above is toggled off. Only this specific rule set (IMMUTABLE_RULE_IDS) carries this guarantee; rules in the Enterprise Rules table below do not.</div></div>
711
+ </div>
712
+ </div>
713
+
714
+ <script>
715
+ const SECRET=${escapedSecret};
716
+ const authHeaders = SECRET ? {'x-mergen-secret': SECRET, 'Content-Type': 'application/json'} : {'Content-Type': 'application/json'};
717
+
718
+ function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
719
+
720
+ function flash(msg, ok=true){
721
+ const el=document.getElementById('flash');
722
+ el.textContent=msg; el.className='flash show '+(ok?'ok':'err');
723
+ setTimeout(()=>{el.className='flash';},3000);
724
+ }
725
+
726
+ async function loadPolicy(){
727
+ const d=await fetch('/policies/json', {headers: SECRET ? {'x-mergen-secret': SECRET} : {}}).then(r=>r.json());
728
+ document.getElementById('policy-enabled').checked=d.enabled;
729
+ document.getElementById('enabled-label').textContent=d.enabled?'Policy active \u2014 gate is enforcing rules':'Policy disabled \u2014 editable Enterprise Rules below are off; Hard Safety Rules stay enforced';
730
+ // Immutable rules render in the read-only "Hard Safety Rules" card above, not
731
+ // in this editable table \u2014 they can't be Saved/Deleted, so showing them here
732
+ // with live-looking action buttons would be misleading.
733
+ const editableRules=d.rules.filter(r=>!r.immutable);
734
+ document.getElementById('rule-count').textContent='('+editableRules.length+' rules)';
735
+ const tbody=document.getElementById('rules-body');
736
+ if(!editableRules.length){tbody.innerHTML='<tr><td colspan="5" style="color:var(--muted);text-align:center;padding:20px">No enterprise rules yet \u2014 add one below.</td></tr>';return;}
737
+ tbody.innerHTML=editableRules.map(r=>\`
738
+ <tr id="row-\${escHtml(r.id)}">
739
+ <td><strong>\${escHtml(r.name)}</strong><br><span style="color:var(--muted);font-size:10px">\${escHtml(r.id)}</span></td>
740
+ <td><select id="action-\${escHtml(r.id)}" style="width:auto">
741
+ <option value="block" \${r.action==='block'?'selected':''}>block</option>
742
+ <option value="warn" \${r.action==='warn'?'selected':''}>warn</option>
743
+ <option value="pass" \${r.action==='pass'?'selected':''}>pass</option>
744
+ </select></td>
745
+ <td><span class="count">\${r.triggerCount||0}</span></td>
746
+ <td><input id="desc-\${escHtml(r.id)}" value="\${escHtml(r.description)}" /></td>
747
+ <td style="white-space:nowrap">
748
+ <button class="btn btn-save" onclick="saveRule('\${escHtml(r.id)}')">Save</button>
749
+ <button class="btn btn-delete" onclick="deleteRule('\${escHtml(r.id)}')">Delete</button>
750
+ </td>
751
+ </tr>
752
+ \`).join('');
753
+ }
754
+
755
+ async function toggleEnabled(val){
756
+ document.getElementById('enabled-label').textContent=val?'Policy active \u2014 gate is enforcing rules':'Policy disabled \u2014 editable Enterprise Rules below are off; Hard Safety Rules stay enforced';
757
+ const r=await fetch('/policies/enabled',{method:'PATCH',headers:authHeaders,body:JSON.stringify({enabled:val})});
758
+ flash(val?'Policy enabled':'Policy disabled \u2014 hard safety rules remain active', r.ok);
759
+ }
760
+
761
+ async function saveRule(id){
762
+ const action=document.getElementById('action-'+id).value;
763
+ const description=document.getElementById('desc-'+id).value;
764
+ const r=await fetch('/policies/rules/'+encodeURIComponent(id),{method:'PATCH',headers:authHeaders,body:JSON.stringify({action,description})});
765
+ flash(r.ok?'Rule saved':'Save failed \u2014 '+await r.text(), r.ok);
766
+ }
767
+
768
+ async function deleteRule(id){
769
+ if(!confirm('Delete rule "'+id+'"?')) return;
770
+ const r=await fetch('/policies/rules/'+encodeURIComponent(id),{method:'DELETE',headers:SECRET?{'x-mergen-secret':SECRET}:{}});
771
+ if(r.ok){flash('Rule deleted'); await loadPolicy();}
772
+ else flash('Delete failed','err');
773
+ }
774
+
775
+ async function addRule(){
776
+ const id=document.getElementById('new-id').value.trim();
777
+ const name=document.getElementById('new-name').value.trim();
778
+ const desc=document.getElementById('new-desc').value.trim();
779
+ const action=document.getElementById('new-action').value;
780
+ const reason=document.getElementById('new-reason').value.trim();
781
+ const cmds=document.getElementById('new-commands').value.split(',').map(s=>s.trim()).filter(Boolean);
782
+ const files=document.getElementById('new-files').value.split(',').map(s=>s.trim()).filter(Boolean);
783
+ const actor=document.getElementById('new-actor').value;
784
+ if(!id||!name||!reason){flash('ID, name, and reason are required','err');return;}
785
+ if(!/^[a-z0-9_-]+$/.test(id)){flash('Rule ID must be lowercase letters, digits, underscores or hyphens only','err');return;}
786
+ const conditions={};
787
+ if(cmds.length) conditions.commands=cmds;
788
+ if(files.length) conditions.files=files;
789
+ if(actor) conditions.actorType=actor;
790
+ const r=await fetch('/policies/rules',{method:'POST',headers:authHeaders,body:JSON.stringify({id,name,description:desc,action,reason,conditions})});
791
+ if(r.ok){
792
+ flash('Rule added');
793
+ ['new-id','new-name','new-desc','new-reason','new-commands','new-files'].forEach(f=>document.getElementById(f).value='');
794
+ await loadPolicy();
795
+ } else {
796
+ const e=await r.json();
797
+ flash('Error: '+JSON.stringify(e.error),'err');
798
+ }
799
+ }
800
+
801
+ loadPolicy();
802
+ setInterval(loadPolicy, 30_000);
803
+ </script>
804
+ </body>
805
+ </html>`;
806
+ }
807
+ export {
808
+ createPoliciesRouter
809
+ };