@datasynx/agentic-ai-cartography 2.3.0 → 2.5.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.
package/dist/index.js CHANGED
@@ -156,15 +156,26 @@ var SECURITY_METADATA_KEYS = [
156
156
  var DriftConfigSchema = z.object({
157
157
  minSeverity: z.enum(SEVERITIES).default("info"),
158
158
  sinks: z.array(z.object({
159
- type: z.enum(["stdout", "webhook"]),
159
+ type: z.enum(["stdout", "webhook", "slack", "pagerduty", "jira"]),
160
160
  url: z.string().url().optional(),
161
161
  token: z.string().optional(),
162
- timeoutMs: z.number().int().positive().optional()
162
+ timeoutMs: z.number().int().positive().optional(),
163
+ routingKey: z.string().optional(),
164
+ email: z.string().optional(),
165
+ project: z.string().optional(),
166
+ issueType: z.string().optional()
163
167
  })).default([{ type: "stdout" }])
164
168
  }).superRefine((cfg, ctx) => {
165
169
  for (const [i, s] of cfg.sinks.entries()) {
166
- if (s.type === "webhook" && !s.url) {
167
- ctx.addIssue({ code: "custom", path: ["sinks", i, "url"], message: "webhook sink requires a url" });
170
+ const requireUrl = (msg) => {
171
+ if (!s.url) ctx.addIssue({ code: "custom", path: ["sinks", i, "url"], message: msg });
172
+ };
173
+ if (s.type === "webhook") requireUrl("webhook sink requires a url");
174
+ if (s.type === "slack") requireUrl("slack sink requires a webhook url");
175
+ if (s.type === "jira") {
176
+ requireUrl("jira sink requires a base url");
177
+ if (!s.email) ctx.addIssue({ code: "custom", path: ["sinks", i, "email"], message: "jira sink requires an email" });
178
+ if (!s.project) ctx.addIssue({ code: "custom", path: ["sinks", i, "project"], message: "jira sink requires a project key" });
168
179
  }
169
180
  }
170
181
  });
@@ -1778,8 +1789,17 @@ function stripSensitive(target) {
1778
1789
  const stripped = `${url.hostname}${url.port ? ":" + url.port : ""}`;
1779
1790
  return stripped || raw;
1780
1791
  } catch {
1781
- const stripped = raw.replace(/\/.*$/, "").replace(/\?.*$/, "").replace(/@.*:/, ":");
1782
- return stripped || raw;
1792
+ let s = raw;
1793
+ const slash = s.indexOf("/");
1794
+ if (slash >= 0) s = s.slice(0, slash);
1795
+ const q = s.indexOf("?");
1796
+ if (q >= 0) s = s.slice(0, q);
1797
+ const at = s.indexOf("@");
1798
+ if (at >= 0) {
1799
+ const colon = s.lastIndexOf(":");
1800
+ if (colon > at) s = s.slice(0, at) + ":" + s.slice(colon + 1);
1801
+ }
1802
+ return s || raw;
1783
1803
  }
1784
1804
  }
1785
1805
  var SCAN_ARG_PATTERNS = {
@@ -1797,7 +1817,7 @@ function assertSafeScanArg(kind, value) {
1797
1817
  return value;
1798
1818
  }
1799
1819
  function redactSecrets(value) {
1800
- return value.replace(/([a-z][a-z0-9+.-]*:\/\/[^:@/\s]+):[^@/\s]+@/gi, "$1:***@");
1820
+ return value.replace(/([a-z][a-z0-9+.-]{0,63}:\/\/[^:@/\s]{1,256}):[^@/\s]{1,256}@/gi, "$1:***@");
1801
1821
  }
1802
1822
  function redactValue(value) {
1803
1823
  if (typeof value === "string") return redactSecrets(value);
@@ -2795,7 +2815,10 @@ CREATE TABLE IF NOT EXISTS activity_events (
2795
2815
  duration_ms INTEGER,
2796
2816
  command TEXT,
2797
2817
  result_bytes INTEGER,
2798
- tenant TEXT NOT NULL DEFAULT 'local'
2818
+ tenant TEXT NOT NULL DEFAULT 'local',
2819
+ actor_subject TEXT,
2820
+ actor_role TEXT,
2821
+ actor_tenant TEXT
2799
2822
  );
2800
2823
 
2801
2824
  CREATE TABLE IF NOT EXISTS tasks (
@@ -2904,6 +2927,16 @@ CREATE INDEX IF NOT EXISTS idx_nodes_tenant_content ON nodes(tenant, content_has
2904
2927
  CREATE INDEX IF NOT EXISTS idx_contrib_org ON node_contributors(organization, global_id);
2905
2928
  CREATE INDEX IF NOT EXISTS idx_nodes_owner ON nodes(session_id, owner);
2906
2929
  `;
2930
+ var AUTH_SCHEMA = `
2931
+ CREATE TABLE IF NOT EXISTS auth_credentials (
2932
+ token_hash TEXT PRIMARY KEY,
2933
+ subject TEXT NOT NULL,
2934
+ tenant TEXT NOT NULL DEFAULT 'local',
2935
+ role TEXT NOT NULL,
2936
+ created_at TEXT NOT NULL
2937
+ );
2938
+ CREATE INDEX IF NOT EXISTS idx_auth_subject ON auth_credentials(subject);
2939
+ `;
2907
2940
  var CartographyDB = class {
2908
2941
  db;
2909
2942
  /** 3.6 anomaly settings; defaults apply when no `anomaly` config is supplied. */
@@ -2923,7 +2956,8 @@ var CartographyDB = class {
2923
2956
  const version = this.db.pragma("user_version", { simple: true });
2924
2957
  if (version === 0) {
2925
2958
  this.db.exec(SCHEMA);
2926
- this.db.pragma("user_version = 14");
2959
+ this.db.exec(AUTH_SCHEMA);
2960
+ this.db.pragma("user_version = 15");
2927
2961
  return;
2928
2962
  } else if (version === 1) {
2929
2963
  const cols = this.db.prepare("PRAGMA table_info(nodes)").all().map((c) => c.name);
@@ -3109,6 +3143,18 @@ var CartographyDB = class {
3109
3143
  }
3110
3144
  this.db.pragma("user_version = 14");
3111
3145
  }
3146
+ const v14 = this.db.pragma("user_version", { simple: true });
3147
+ if (v14 < 15) {
3148
+ this.db.exec(AUTH_SCHEMA);
3149
+ const ev = this.db.prepare("PRAGMA table_info(activity_events)").all();
3150
+ if (ev.length > 0) {
3151
+ const cols = ev.map((c) => c.name);
3152
+ if (!cols.includes("actor_subject")) this.db.exec("ALTER TABLE activity_events ADD COLUMN actor_subject TEXT");
3153
+ if (!cols.includes("actor_role")) this.db.exec("ALTER TABLE activity_events ADD COLUMN actor_role TEXT");
3154
+ if (!cols.includes("actor_tenant")) this.db.exec("ALTER TABLE activity_events ADD COLUMN actor_tenant TEXT");
3155
+ }
3156
+ this.db.pragma("user_version = 15");
3157
+ }
3112
3158
  }
3113
3159
  close() {
3114
3160
  this.db.pragma("optimize");
@@ -3539,13 +3585,13 @@ var CartographyDB = class {
3539
3585
  });
3540
3586
  }
3541
3587
  // ── Events ──────────────────────────────
3542
- insertEvent(sessionId, event, taskId) {
3588
+ insertEvent(sessionId, event, taskId, actor) {
3543
3589
  const id = crypto.randomUUID();
3544
3590
  const tenant = this.tenantOf(sessionId);
3545
3591
  this.db.prepare(`
3546
3592
  INSERT INTO activity_events
3547
- (id, session_id, task_id, timestamp, event_type, process, pid, target, target_type, port, command, result_bytes, tenant)
3548
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3593
+ (id, session_id, task_id, timestamp, event_type, process, pid, target, target_type, port, command, result_bytes, tenant, actor_subject, actor_role, actor_tenant)
3594
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3549
3595
  `).run(
3550
3596
  id,
3551
3597
  sessionId,
@@ -3559,9 +3605,52 @@ var CartographyDB = class {
3559
3605
  event.port ?? null,
3560
3606
  event.command ?? null,
3561
3607
  event.resultBytes ?? null,
3562
- tenant
3608
+ tenant,
3609
+ actor?.subject ?? null,
3610
+ actor?.role ?? null,
3611
+ actor?.tenant ?? null
3563
3612
  );
3564
3613
  }
3614
+ // ── RBAC credential store (4.5) ─────────────
3615
+ /** Number of stored credentials. `0` ⇒ no RBAC configured (fall back to shared/loopback). */
3616
+ countCredentials() {
3617
+ return this.db.prepare("SELECT COUNT(*) AS n FROM auth_credentials").get().n;
3618
+ }
3619
+ /** Look up a credential by its sha256 token hash. */
3620
+ findCredentialByHash(tokenHash) {
3621
+ const r = this.db.prepare("SELECT * FROM auth_credentials WHERE token_hash = ?").get(tokenHash);
3622
+ if (!r) return void 0;
3623
+ return {
3624
+ tokenHash: r["token_hash"],
3625
+ subject: r["subject"],
3626
+ tenant: r["tenant"],
3627
+ role: r["role"],
3628
+ createdAt: r["created_at"]
3629
+ };
3630
+ }
3631
+ /** Upsert a credential (idempotent on the token hash). Stores only the hash, never the raw token. */
3632
+ addCredential(rec) {
3633
+ this.db.prepare(`
3634
+ INSERT INTO auth_credentials (token_hash, subject, tenant, role, created_at)
3635
+ VALUES (?, ?, ?, ?, ?)
3636
+ ON CONFLICT(token_hash) DO UPDATE SET subject = excluded.subject, tenant = excluded.tenant, role = excluded.role
3637
+ `).run(rec.tokenHash, rec.subject, rec.tenant, rec.role, (/* @__PURE__ */ new Date()).toISOString());
3638
+ }
3639
+ /** List all credentials (token hashes only — the raw token is unrecoverable). */
3640
+ listCredentials() {
3641
+ const rows = this.db.prepare("SELECT * FROM auth_credentials ORDER BY created_at").all();
3642
+ return rows.map((r) => ({
3643
+ tokenHash: r["token_hash"],
3644
+ subject: r["subject"],
3645
+ tenant: r["tenant"],
3646
+ role: r["role"],
3647
+ createdAt: r["created_at"]
3648
+ }));
3649
+ }
3650
+ /** Revoke every credential for a subject. Returns the number removed. */
3651
+ revokeCredentialsBySubject(subject) {
3652
+ return this.db.prepare("DELETE FROM auth_credentials WHERE subject = ?").run(subject).changes;
3653
+ }
3565
3654
  getEvents(sessionId, since) {
3566
3655
  const rows = since ? this.db.prepare("SELECT * FROM activity_events WHERE session_id = ? AND timestamp > ? ORDER BY timestamp").all(sessionId, since) : this.db.prepare("SELECT * FROM activity_events WHERE session_id = ? ORDER BY timestamp").all(sessionId);
3567
3656
  return rows.map((r) => {
@@ -4895,6 +4984,41 @@ var StdoutSink = class {
4895
4984
 
4896
4985
  // src/sinks/webhook.ts
4897
4986
  var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
4987
+ async function postJson(opts) {
4988
+ const doFetch = opts.fetchImpl ?? (typeof fetch === "function" ? fetch : void 0);
4989
+ if (!doFetch) {
4990
+ logWarn("sink unavailable: global fetch missing", { sink: opts.sinkName });
4991
+ return;
4992
+ }
4993
+ if (!opts.url) {
4994
+ logWarn("sink unavailable: no url configured", { sink: opts.sinkName });
4995
+ return;
4996
+ }
4997
+ if (!isSecureWebhookUrl(opts.url)) {
4998
+ logWarn("sink refused: insecure scheme (use https:// or a loopback host)", {
4999
+ sink: opts.sinkName,
5000
+ host: stripSensitive(opts.url)
5001
+ });
5002
+ return;
5003
+ }
5004
+ try {
5005
+ const res = await doFetch(opts.url, {
5006
+ method: "POST",
5007
+ headers: { "content-type": "application/json", ...opts.headers ?? {} },
5008
+ body: JSON.stringify(opts.body),
5009
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
5010
+ });
5011
+ if (!res.ok) {
5012
+ logError("sink delivery failed", { sink: opts.sinkName, host: stripSensitive(opts.url), status: res.status });
5013
+ }
5014
+ } catch (err) {
5015
+ logError("sink delivery failed", {
5016
+ sink: opts.sinkName,
5017
+ host: stripSensitive(opts.url),
5018
+ reason: err instanceof Error ? err.message : String(err)
5019
+ });
5020
+ }
5021
+ }
4898
5022
  function isSecureWebhookUrl(url, env = process.env) {
4899
5023
  if (env.CARTOGRAPHY_ALLOW_INSECURE_SYNC === "1") return true;
4900
5024
  let parsed;
@@ -4913,59 +5037,177 @@ var WebhookSink = class {
4913
5037
  }
4914
5038
  name = "webhook";
4915
5039
  async emit(alert) {
4916
- if (typeof fetch !== "function") {
4917
- logWarn("webhook sink unavailable: global fetch missing", { sink: this.name });
4918
- return;
4919
- }
4920
5040
  const { url, token, timeoutMs } = this.opts;
4921
- if (!url) {
4922
- logWarn("webhook sink unavailable: no url configured", { sink: this.name });
4923
- return;
4924
- }
4925
- if (!isSecureWebhookUrl(url)) {
4926
- logWarn("webhook sink refused: insecure scheme (use https:// or a loopback host)", {
4927
- sink: this.name,
4928
- host: stripSensitive(url)
4929
- });
4930
- return;
4931
- }
4932
- try {
4933
- const res = await fetch(url, {
4934
- method: "POST",
4935
- headers: {
4936
- "content-type": "application/json",
4937
- ...token ? { authorization: `Bearer ${token}` } : {}
4938
- },
4939
- body: JSON.stringify(redactValue(alert)),
4940
- signal: AbortSignal.timeout(timeoutMs ?? 1e4)
4941
- });
4942
- if (!res.ok) {
4943
- logError("webhook sink failed", { sink: this.name, host: stripSensitive(url), status: res.status });
5041
+ await postJson({
5042
+ url,
5043
+ body: redactValue(alert),
5044
+ ...token ? { headers: { authorization: `Bearer ${token}` } } : {},
5045
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
5046
+ sinkName: this.name
5047
+ });
5048
+ }
5049
+ };
5050
+
5051
+ // src/sinks/providers.ts
5052
+ var MAX_ITEMS2 = 20;
5053
+ var SEVERITY_EMOJI = { info: "\u{1F7E2}", warning: "\u{1F7E1}", critical: "\u{1F534}" };
5054
+ function headline(alert) {
5055
+ const s = alert.summary;
5056
+ return `${s.nodesAdded}+ / ${s.nodesRemoved}- / ${s.nodesChanged}~ nodes, ${s.edgesAdded}+ / ${s.edgesRemoved}- edges`;
5057
+ }
5058
+ function itemLine(it) {
5059
+ const sec = it.securityFields?.length ? ` [security: ${it.securityFields.join(", ")}]` : "";
5060
+ const fields = it.changedFields?.length ? ` (${it.changedFields.join(", ")})` : "";
5061
+ return `${it.severity.toUpperCase()} \xB7 ${it.kind} \xB7 ${it.label}${fields}${sec}`;
5062
+ }
5063
+ function bodyText(alert) {
5064
+ const lines = alert.items.slice(0, MAX_ITEMS2).map(itemLine);
5065
+ const more = alert.items.length > MAX_ITEMS2 ? [`\u2026and ${alert.items.length - MAX_ITEMS2} more`] : [];
5066
+ return [headline(alert), "", ...lines, ...more].join("\n");
5067
+ }
5068
+ function formatSlack(alert) {
5069
+ const title = `${SEVERITY_EMOJI[alert.severity]} Topology drift \u2014 ${alert.severity}`;
5070
+ return {
5071
+ text: `${title}: ${headline(alert)}`,
5072
+ blocks: [
5073
+ { type: "header", text: { type: "plain_text", text: title, emoji: true } },
5074
+ { type: "section", text: { type: "mrkdwn", text: "```" + bodyText(alert) + "```" } },
5075
+ { type: "context", elements: [{ type: "mrkdwn", text: `base ${alert.base.sessionId} \u2192 current ${alert.current.sessionId} \xB7 ${alert.generatedAt}` }] }
5076
+ ]
5077
+ };
5078
+ }
5079
+ var PD_SEVERITY = {
5080
+ info: "info",
5081
+ warning: "warning",
5082
+ critical: "critical"
5083
+ };
5084
+ function formatPagerDuty(alert, routingKey) {
5085
+ return {
5086
+ routing_key: routingKey,
5087
+ event_action: "trigger",
5088
+ // Stable per base→current pair so repeated alerts for the same delta de-duplicate.
5089
+ dedup_key: `cartograph-drift:${alert.base.sessionId}:${alert.current.sessionId}`,
5090
+ payload: {
5091
+ summary: `Cartograph topology drift (${alert.severity}): ${headline(alert)}`,
5092
+ source: "cartograph",
5093
+ severity: PD_SEVERITY[alert.severity],
5094
+ timestamp: alert.generatedAt,
5095
+ custom_details: {
5096
+ summary: alert.summary,
5097
+ items: alert.items.slice(0, MAX_ITEMS2).map((it) => ({
5098
+ kind: it.kind,
5099
+ ref: it.ref,
5100
+ severity: it.severity,
5101
+ ...it.changedFields ? { changedFields: it.changedFields } : {},
5102
+ ...it.securityFields ? { securityFields: it.securityFields } : {}
5103
+ }))
4944
5104
  }
4945
- } catch (err) {
4946
- logError("webhook sink failed", {
4947
- sink: this.name,
4948
- host: stripSensitive(url),
4949
- reason: err instanceof Error ? err.message : String(err)
4950
- });
4951
5105
  }
5106
+ };
5107
+ }
5108
+ function formatJira(alert, opts) {
5109
+ return {
5110
+ fields: {
5111
+ project: { key: opts.project },
5112
+ issuetype: { name: opts.issueType ?? "Task" },
5113
+ summary: `Cartograph topology drift (${alert.severity}): ${headline(alert)}`,
5114
+ description: bodyText(alert) + `
5115
+
5116
+ base ${alert.base.sessionId} \u2192 current ${alert.current.sessionId}
5117
+ generated ${alert.generatedAt}`
5118
+ }
5119
+ };
5120
+ }
5121
+
5122
+ // src/sinks/provider-sink.ts
5123
+ var PAGERDUTY_ENQUEUE_URL = "https://events.pagerduty.com/v2/enqueue";
5124
+ function deliver(name, url, body, opts, headers) {
5125
+ return postJson({
5126
+ url,
5127
+ body,
5128
+ sinkName: name,
5129
+ ...headers ? { headers } : {},
5130
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
5131
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
5132
+ });
5133
+ }
5134
+ var SlackSink = class {
5135
+ constructor(opts) {
5136
+ this.opts = opts;
5137
+ }
5138
+ name = "slack";
5139
+ async emit(alert) {
5140
+ await deliver(this.name, this.opts.url, formatSlack(redactValue(alert)), this.opts);
5141
+ }
5142
+ };
5143
+ var PagerDutySink = class {
5144
+ constructor(opts) {
5145
+ this.opts = opts;
5146
+ }
5147
+ name = "pagerduty";
5148
+ async emit(alert) {
5149
+ const body = formatPagerDuty(redactValue(alert), this.opts.routingKey);
5150
+ await deliver(this.name, this.opts.url || PAGERDUTY_ENQUEUE_URL, body, this.opts);
5151
+ }
5152
+ };
5153
+ var JiraSink = class {
5154
+ constructor(opts) {
5155
+ this.opts = opts;
5156
+ }
5157
+ name = "jira";
5158
+ async emit(alert) {
5159
+ const body = formatJira(redactValue(alert), {
5160
+ project: this.opts.project,
5161
+ ...this.opts.issueType ? { issueType: this.opts.issueType } : {}
5162
+ });
5163
+ const auth = Buffer.from(`${this.opts.email}:${this.opts.token}`).toString("base64");
5164
+ const base = this.opts.url.replace(/\/+$/, "");
5165
+ await deliver(this.name, `${base}/rest/api/2/issue`, body, this.opts, { authorization: `Basic ${auth}` });
4952
5166
  }
4953
5167
  };
4954
5168
 
4955
5169
  // src/sinks/index.ts
4956
5170
  function buildSinks(drift) {
4957
5171
  const configs = drift?.sinks && drift.sinks.length > 0 ? drift.sinks : [{ type: "stdout" }];
5172
+ const envSecret = process.env.CARTOGRAPHY_DRIFT_TOKEN;
4958
5173
  const sinks = [];
4959
5174
  for (const s of configs) {
4960
- if (s.type === "webhook") {
4961
- if (!s.url) continue;
4962
- sinks.push(new WebhookSink({
4963
- url: s.url,
4964
- token: s.token ?? process.env.CARTOGRAPHY_DRIFT_TOKEN,
4965
- timeoutMs: s.timeoutMs
4966
- }));
4967
- } else {
4968
- sinks.push(new StdoutSink());
5175
+ const timeoutMs = s.timeoutMs;
5176
+ switch (s.type) {
5177
+ case "webhook":
5178
+ if (!s.url) {
5179
+ logWarn("drift sink skipped: webhook requires a url", { sink: s.type });
5180
+ break;
5181
+ }
5182
+ sinks.push(new WebhookSink({ url: s.url, token: s.token ?? envSecret, timeoutMs }));
5183
+ break;
5184
+ case "slack":
5185
+ if (!s.url) {
5186
+ logWarn("drift sink skipped: slack requires a webhook url", { sink: s.type });
5187
+ break;
5188
+ }
5189
+ sinks.push(new SlackSink({ url: s.url, timeoutMs }));
5190
+ break;
5191
+ case "pagerduty": {
5192
+ const routingKey = s.routingKey ?? s.token ?? envSecret;
5193
+ if (!routingKey) {
5194
+ logWarn("drift sink skipped: pagerduty requires a routingKey (or CARTOGRAPHY_DRIFT_TOKEN)", { sink: s.type });
5195
+ break;
5196
+ }
5197
+ sinks.push(new PagerDutySink({ url: s.url ?? PAGERDUTY_ENQUEUE_URL, routingKey, timeoutMs }));
5198
+ break;
5199
+ }
5200
+ case "jira": {
5201
+ const token = s.token ?? envSecret;
5202
+ if (!s.url || !s.email || !s.project || !token) {
5203
+ logWarn("drift sink skipped: jira requires url, email, project and a token", { sink: s.type });
5204
+ break;
5205
+ }
5206
+ sinks.push(new JiraSink({ url: s.url, email: s.email, token, project: s.project, issueType: s.issueType, timeoutMs }));
5207
+ break;
5208
+ }
5209
+ default:
5210
+ sinks.push(new StdoutSink());
4969
5211
  }
4970
5212
  }
4971
5213
  return sinks.length > 0 ? sinks : [new StdoutSink()];
@@ -5087,6 +5329,36 @@ async function runDrift(db, config, opts = {}) {
5087
5329
  return alert;
5088
5330
  }
5089
5331
 
5332
+ // src/auth/rbac.ts
5333
+ var ROLE_RANK = { viewer: 1, operator: 2, admin: 3 };
5334
+ var ACTION_MIN_ROLE = { read: "viewer", discovery: "operator", admin: "admin" };
5335
+ function can(role, action) {
5336
+ return ROLE_RANK[role] >= ROLE_RANK[ACTION_MIN_ROLE[action]];
5337
+ }
5338
+ var AuthorizationError = class extends Error {
5339
+ constructor(action, role) {
5340
+ super(`forbidden: role '${role}' may not perform '${action}'`);
5341
+ this.action = action;
5342
+ this.role = role;
5343
+ this.name = "AuthorizationError";
5344
+ }
5345
+ };
5346
+ function authorize(principal, action) {
5347
+ if (!can(principal.role, action)) throw new AuthorizationError(action, principal.role);
5348
+ }
5349
+ var TenantMismatchError = class extends Error {
5350
+ constructor() {
5351
+ super("forbidden: principal is not scoped to the requested tenant");
5352
+ this.name = "TenantMismatchError";
5353
+ }
5354
+ };
5355
+ function scopeReads(principal) {
5356
+ return principal.tenant;
5357
+ }
5358
+ function assertSameTenant(principal, requestedTenant) {
5359
+ if (requestedTenant !== principal.tenant) throw new TenantMismatchError();
5360
+ }
5361
+
5090
5362
  // src/compliance/rulesets/baseline.ts
5091
5363
  var baseline = RulesetSchema.parse({
5092
5364
  name: "baseline",
@@ -5374,7 +5646,7 @@ async function resolveNlQuery(db, sessionId, search, raw, opts) {
5374
5646
 
5375
5647
  // src/mcp/server.ts
5376
5648
  var SERVER_NAME = "cartography";
5377
- var SERVER_VERSION = "2.3.0";
5649
+ var SERVER_VERSION = "2.5.0";
5378
5650
  var SERVICE_TYPES = NODE_TYPE_GROUPS.web;
5379
5651
  var DATA_TYPES = NODE_TYPE_GROUPS.data;
5380
5652
  var lexicalSearch = async (db, sessionId, query, opts) => db.searchNodes(sessionId, query, { types: opts.types, limit: opts.limit }).map((node) => ({ node }));
@@ -5776,6 +6048,14 @@ function createMcpServer(opts = {}) {
5776
6048
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
5777
6049
  },
5778
6050
  async (args) => {
6051
+ if (opts.principal) {
6052
+ try {
6053
+ authorize(opts.principal, "discovery");
6054
+ } catch (err) {
6055
+ if (err instanceof AuthorizationError) return json({ error: `forbidden: role '${opts.principal.role}' may not run discovery (operator required)` });
6056
+ throw err;
6057
+ }
6058
+ }
5779
6059
  let sid = resolveSession();
5780
6060
  if (args.update) {
5781
6061
  if (!sid) return json({ error: "No session to update; run discovery first." });
@@ -5918,7 +6198,41 @@ function defaultAllowedHosts(host2, port) {
5918
6198
  return [`${host2}:${port}`, `localhost:${port}`, `127.0.0.1:${port}`];
5919
6199
  }
5920
6200
 
6201
+ // src/auth/identity.ts
6202
+ import { createHash as createHash2 } from "crypto";
6203
+ function hashToken(token) {
6204
+ return createHash2("sha256").update(token, "utf8").digest("hex");
6205
+ }
6206
+ var SqliteCredentialStore = class {
6207
+ constructor(db) {
6208
+ this.db = db;
6209
+ }
6210
+ count() {
6211
+ return this.db.countCredentials();
6212
+ }
6213
+ findByHash(tokenHash) {
6214
+ return this.db.findCredentialByHash(tokenHash);
6215
+ }
6216
+ };
6217
+ function resolvePrincipal(presentedToken, opts) {
6218
+ const tenant = opts.defaultTenant ?? DEFAULT_TENANT;
6219
+ if (opts.store && opts.store.count() > 0) {
6220
+ if (!presentedToken) return void 0;
6221
+ const rec = opts.store.findByHash(hashToken(presentedToken));
6222
+ return rec ? { subject: rec.subject, tenant: rec.tenant, role: rec.role } : void 0;
6223
+ }
6224
+ if (opts.sharedToken) {
6225
+ if (!presentedToken || !timingSafeEqual(presentedToken, opts.sharedToken)) return void 0;
6226
+ return { subject: "shared-token", tenant, role: "admin" };
6227
+ }
6228
+ if (opts.required) return void 0;
6229
+ return { subject: "anonymous", tenant, role: "admin" };
6230
+ }
6231
+
5921
6232
  // src/mcp/transports.ts
6233
+ function samePrincipal(a, b) {
6234
+ return a.subject === b.subject && a.tenant === b.tenant && a.role === b.role;
6235
+ }
5922
6236
  async function runStdio(server) {
5923
6237
  const transport = new StdioServerTransport();
5924
6238
  await server.connect(transport);
@@ -5963,6 +6277,14 @@ async function runHttp(factory, opts = {}) {
5963
6277
  assertSafeBind({ host: host2, port, ...opts.allowedHosts ? { allowedHosts: opts.allowedHosts } : {}, ...opts.token ? { token: opts.token } : {} });
5964
6278
  const allowedHosts = opts.allowedHosts ?? defaultAllowedHosts(host2, port);
5965
6279
  const token = opts.token;
6280
+ const authStore = opts.auth?.store;
6281
+ const defaultTenant = opts.defaultTenant;
6282
+ const resolveAuth = (header) => resolvePrincipal(bearerToken(header), {
6283
+ ...authStore ? { store: authStore } : {},
6284
+ ...token ? { sharedToken: token } : {},
6285
+ ...defaultTenant ? { defaultTenant } : {},
6286
+ ...opts.auth?.required ? { required: true } : {}
6287
+ });
5966
6288
  const transports = /* @__PURE__ */ new Map();
5967
6289
  const httpServer = http.createServer(async (req, res) => {
5968
6290
  try {
@@ -5972,7 +6294,8 @@ async function runHttp(factory, opts = {}) {
5972
6294
  res.writeHead(404, { "content-type": "application/json" }).end('{"error":"not found"}');
5973
6295
  return;
5974
6296
  }
5975
- if (!checkBearer(req.headers["authorization"], token)) {
6297
+ const principal = resolveAuth(req.headers["authorization"]);
6298
+ if (!principal) {
5976
6299
  res.writeHead(401, { "content-type": "application/json", "www-authenticate": "Bearer" }).end('{"error":"unauthorized"}');
5977
6300
  return;
5978
6301
  }
@@ -5999,8 +6322,12 @@ async function runHttp(factory, opts = {}) {
5999
6322
  const sessionId = req.headers["mcp-session-id"];
6000
6323
  const existing = sessionId ? transports.get(sessionId) : void 0;
6001
6324
  if (existing) {
6325
+ if (!samePrincipal(existing.principal, principal)) {
6326
+ res.writeHead(403, { "content-type": "application/json" }).end('{"error":"session belongs to a different principal"}');
6327
+ return;
6328
+ }
6002
6329
  const body2 = req.method === "POST" ? await readJsonBody(req) : void 0;
6003
- await existing.handleRequest(req, res, body2);
6330
+ await existing.transport.handleRequest(req, res, body2);
6004
6331
  return;
6005
6332
  }
6006
6333
  if (req.method !== "POST") {
@@ -6014,13 +6341,13 @@ async function runHttp(factory, opts = {}) {
6014
6341
  allowedHosts,
6015
6342
  ...opts.allowedOrigins ? { allowedOrigins: opts.allowedOrigins } : {},
6016
6343
  onsessioninitialized: (id) => {
6017
- transports.set(id, transport);
6344
+ transports.set(id, { transport, principal });
6018
6345
  }
6019
6346
  });
6020
6347
  transport.onclose = () => {
6021
6348
  if (transport.sessionId) transports.delete(transport.sessionId);
6022
6349
  };
6023
- await factory().connect(transport);
6350
+ await factory(principal).connect(transport);
6024
6351
  await transport.handleRequest(req, res, body);
6025
6352
  } catch (err) {
6026
6353
  process.stderr.write(`[cartography-mcp] HTTP request failed: ${err instanceof Error ? err.message : String(err)}
@@ -7647,6 +7974,8 @@ async function runApi(opts) {
7647
7974
  const token = opts.token;
7648
7975
  const graphqlEnabled = opts.graphql !== false;
7649
7976
  const defaultTenant = opts.tenant?.defaultTenant ?? DEFAULT_TENANT;
7977
+ const authStore = opts.auth?.store;
7978
+ const rbacMode = !!(authStore && authStore.count() > 0);
7650
7979
  const log2 = opts.log ?? (() => {
7651
7980
  });
7652
7981
  const restDeps = { backend: opts.backend, version: opts.version };
@@ -7705,23 +8034,44 @@ async function runApi(opts) {
7705
8034
  finish(r.status);
7706
8035
  return;
7707
8036
  }
7708
- if (!checkBearer(req.headers["authorization"], token)) {
8037
+ const principal = resolvePrincipal(bearerToken(req.headers["authorization"]), {
8038
+ ...authStore ? { store: authStore } : {},
8039
+ ...token ? { sharedToken: token } : {},
8040
+ defaultTenant,
8041
+ ...opts.auth?.required ? { required: true } : {}
8042
+ });
8043
+ if (!principal) {
7709
8044
  send(res, 401, { error: "unauthorized" }, { "www-authenticate": "Bearer", ...cors });
7710
8045
  finish(401);
7711
8046
  return;
7712
8047
  }
7713
- let ctx;
7714
8048
  try {
7715
- ctx = resolveTenant(req, url, opts.tenant ?? {});
7716
- tenantLabel = ctx.tenant;
8049
+ authorize(principal, "read");
7717
8050
  } catch (err) {
7718
- if (err instanceof InvalidTenantError) {
7719
- send(res, 400, { error: "invalid tenant" }, cors);
7720
- finish(400);
8051
+ if (err instanceof AuthorizationError) {
8052
+ send(res, 403, { error: "forbidden" }, cors);
8053
+ finish(403);
7721
8054
  return;
7722
8055
  }
7723
8056
  throw err;
7724
8057
  }
8058
+ let ctx;
8059
+ if (rbacMode) {
8060
+ ctx = { tenant: principal.tenant };
8061
+ tenantLabel = principal.tenant;
8062
+ } else {
8063
+ try {
8064
+ ctx = resolveTenant(req, url, opts.tenant ?? {});
8065
+ tenantLabel = ctx.tenant;
8066
+ } catch (err) {
8067
+ if (err instanceof InvalidTenantError) {
8068
+ send(res, 400, { error: "invalid tenant" }, cors);
8069
+ finish(400);
8070
+ return;
8071
+ }
8072
+ throw err;
8073
+ }
8074
+ }
7725
8075
  if (graphqlEnabled && path === "/graphql") {
7726
8076
  if (req.method === "GET") {
7727
8077
  const g = handleGraphqlGet();
@@ -7791,6 +8141,30 @@ function dispatchRest(ctx, path, url, deps) {
7791
8141
  }
7792
8142
  }
7793
8143
 
8144
+ // src/auth/types.ts
8145
+ import { z as z9 } from "zod";
8146
+ var ROLES = ["viewer", "operator", "admin"];
8147
+ var RoleSchema = z9.enum(ROLES);
8148
+ var ACTIONS = ["read", "discovery", "admin"];
8149
+ var ActionSchema = z9.enum(ACTIONS);
8150
+ var PrincipalSchema = z9.object({
8151
+ subject: z9.string().min(1),
8152
+ tenant: z9.string().min(1),
8153
+ role: RoleSchema
8154
+ });
8155
+ var CredentialConfigSchema = z9.object({
8156
+ token: z9.string().min(1),
8157
+ subject: z9.string().min(1),
8158
+ tenant: z9.string().optional(),
8159
+ role: RoleSchema.default("viewer")
8160
+ });
8161
+ var AuthConfigSchema = z9.object({
8162
+ /** Seed credentials (merged into the SQLite store on startup). */
8163
+ credentials: z9.array(CredentialConfigSchema).optional(),
8164
+ /** Reject unauthenticated requests even on loopback (default: loopback dev stays open). */
8165
+ required: z9.boolean().optional()
8166
+ });
8167
+
7794
8168
  // src/api/start.ts
7795
8169
  import { readFileSync as readFileSync4 } from "fs";
7796
8170
  import { dirname as dirname3, resolve } from "path";
@@ -7817,6 +8191,7 @@ function parseApiArgs(argv) {
7817
8191
  else if (a === "--db") opts.dbPath = argv[++i];
7818
8192
  else if (a === "--session") opts.session = argv[++i];
7819
8193
  else if (a === "--tenant" || a === "--org") opts.tenant = argv[++i];
8194
+ else if (a === "--auth-required") opts.authRequired = true;
7820
8195
  else if (a === "--help" || a === "-h") opts.help = true;
7821
8196
  }
7822
8197
  return opts;
@@ -7832,11 +8207,13 @@ async function startApi(opts = {}) {
7832
8207
  const host2 = opts.host ?? "127.0.0.1";
7833
8208
  const port = opts.port ?? 3737;
7834
8209
  const version = readVersion();
8210
+ const authStore = new SqliteCredentialStore(db);
7835
8211
  const server = await runApi({
7836
8212
  host: host2,
7837
8213
  port,
7838
8214
  backend,
7839
8215
  version,
8216
+ auth: { store: authStore, ...opts.authRequired ? { required: true } : {} },
7840
8217
  ...opts.allowedHosts ? { allowedHosts: opts.allowedHosts } : {},
7841
8218
  ...opts.allowedOrigins ? { allowedOrigins: opts.allowedOrigins } : {},
7842
8219
  ...token ? { token } : {},
@@ -8331,13 +8708,13 @@ function createClaudeProvider() {
8331
8708
  }
8332
8709
 
8333
8710
  // src/providers/shell.ts
8334
- import { z as z9 } from "zod";
8711
+ import { z as z10 } from "zod";
8335
8712
  function createBashTool() {
8336
8713
  const shell = IS_WIN ? "powershell" : "posix";
8337
8714
  return {
8338
8715
  name: "Bash",
8339
8716
  description: "Run a read-only shell command (inspect ports, processes, config). Mutating or destructive commands are blocked by the read-only allowlist.",
8340
- inputShape: { command: z9.string().describe("The read-only shell command to run") },
8717
+ inputShape: { command: z10.string().describe("The read-only shell command to run") },
8341
8718
  annotations: { readOnlyHint: true, openWorldHint: true },
8342
8719
  handler: async (args) => {
8343
8720
  const command = String(args["command"] ?? "").trim();
@@ -10849,9 +11226,9 @@ async function runOnce(cfg, db) {
10849
11226
  }
10850
11227
 
10851
11228
  // src/sync/hash.ts
10852
- import { createHash as createHash2 } from "crypto";
11229
+ import { createHash as createHash3 } from "crypto";
10853
11230
  function shareHash(kind, payload) {
10854
- return createHash2("sha256").update(stableStringify({ kind, payload })).digest("hex");
11231
+ return createHash3("sha256").update(stableStringify({ kind, payload })).digest("hex");
10855
11232
  }
10856
11233
 
10857
11234
  // src/sync/classify.ts
@@ -10895,7 +11272,7 @@ function classify2(input) {
10895
11272
  }
10896
11273
 
10897
11274
  // src/sync/push.ts
10898
- import { createHash as createHash3 } from "crypto";
11275
+ import { createHash as createHash4 } from "crypto";
10899
11276
  var PUSH_SCHEMA_VERSION = 1;
10900
11277
  var DEFAULT_BATCH = 100;
10901
11278
  var DEFAULT_RETRIES = 4;
@@ -10909,7 +11286,7 @@ function defaultSleep(ms) {
10909
11286
  }
10910
11287
  function batchKey(items) {
10911
11288
  const hashes = items.map((i) => i.contentHash).sort();
10912
- return createHash3("sha256").update(stableStringify(hashes)).digest("hex");
11289
+ return createHash4("sha256").update(stableStringify(hashes)).digest("hex");
10913
11290
  }
10914
11291
  async function pushDeltas(config, items, opts = {}) {
10915
11292
  const central = config.centralDb;
@@ -11114,6 +11491,10 @@ function checkClaudePrerequisites() {
11114
11491
  }
11115
11492
  }
11116
11493
  export {
11494
+ ACTIONS,
11495
+ ActionSchema,
11496
+ AuthConfigSchema,
11497
+ AuthorizationError,
11117
11498
  CLIENTS,
11118
11499
  CONFIDENCE,
11119
11500
  CartographyDB,
@@ -11122,6 +11503,7 @@ export {
11122
11503
  ConditionSchema,
11123
11504
  ConfigError,
11124
11505
  ControlResultSchema,
11506
+ CredentialConfigSchema,
11125
11507
  CsvCostSource,
11126
11508
  DEFAULT_ANOMALY_THRESHOLDS,
11127
11509
  DEFAULT_SERVER_NAME,
@@ -11130,16 +11512,22 @@ export {
11130
11512
  INGEST_SCHEMA_VERSION,
11131
11513
  IngestEnvelopeSchema,
11132
11514
  InvalidTenantError,
11515
+ JiraSink,
11133
11516
  LOOPBACK_HOSTS2 as LOOPBACK_HOSTS,
11134
11517
  MCP_BIN,
11135
11518
  NotFoundError,
11136
11519
  PACKAGE_NAME,
11520
+ PAGERDUTY_ENQUEUE_URL,
11137
11521
  PERSONAL,
11138
11522
  PORT_MAP,
11139
11523
  PRIVATE_IP,
11140
11524
  PUSH_SCHEMA_VERSION,
11525
+ PagerDutySink,
11526
+ PrincipalSchema,
11141
11527
  ProviderRegistry,
11142
11528
  RELATION_TO_DIRECTION,
11529
+ ROLES,
11530
+ RoleSchema,
11143
11531
  RuleCheckSchema,
11144
11532
  RulesetSchema,
11145
11533
  SCAN_ARG_PATTERNS,
@@ -11149,10 +11537,13 @@ export {
11149
11537
  ScannerRegistry,
11150
11538
  ScannerShape,
11151
11539
  SharingLevelSchema,
11540
+ SlackSink,
11541
+ SqliteCredentialStore,
11152
11542
  SqliteQueryBackend,
11153
11543
  SqliteStoreBackend,
11154
11544
  StdoutSink,
11155
11545
  TENANT_HEADER,
11546
+ TenantMismatchError,
11156
11547
  VectorStore,
11157
11548
  WebhookSink,
11158
11549
  applyInstall,
@@ -11160,7 +11551,9 @@ export {
11160
11551
  assertReadOnly,
11161
11552
  assertSafeBind,
11162
11553
  assertSafeScanArg,
11554
+ assertSameTenant,
11163
11555
  assignColors,
11556
+ authorize,
11164
11557
  bearerToken,
11165
11558
  bookmarksScanner,
11166
11559
  buildCartographyToolHandlers,
@@ -11168,6 +11561,7 @@ export {
11168
11561
  buildOpenApiDocument,
11169
11562
  buildReport,
11170
11563
  buildSinks,
11564
+ can,
11171
11565
  centralDbFromEnv,
11172
11566
  checkBearer,
11173
11567
  checkPrerequisites,
@@ -11233,6 +11627,9 @@ export {
11233
11627
  filterBySeverity,
11234
11628
  findAnonViolations,
11235
11629
  formatComplianceText,
11630
+ formatJira,
11631
+ formatPagerDuty,
11632
+ formatSlack,
11236
11633
  generateDependencyMermaid,
11237
11634
  generateDiffMermaid,
11238
11635
  generateTopologyMermaid,
@@ -11241,6 +11638,7 @@ export {
11241
11638
  globalId,
11242
11639
  groupByDomain,
11243
11640
  handleGraphqlGet,
11641
+ hashToken,
11244
11642
  hexCorners,
11245
11643
  hexDistance,
11246
11644
  hexNeighbors,
@@ -11255,6 +11653,7 @@ export {
11255
11653
  isPersonalHost,
11256
11654
  isReadOnlyCommand,
11257
11655
  isRemembered,
11656
+ isSecureWebhookUrl,
11258
11657
  k8sScanner,
11259
11658
  keyMetaOf,
11260
11659
  layoutClusters,
@@ -11293,6 +11692,7 @@ export {
11293
11692
  pixelToHex,
11294
11693
  planInstall,
11295
11694
  portsScanner,
11695
+ postJson,
11296
11696
  previewShare,
11297
11697
  pseudonymize,
11298
11698
  pseudonymizeFragment,
@@ -11305,6 +11705,7 @@ export {
11305
11705
  renderDiff,
11306
11706
  resolveEffectiveLevel,
11307
11707
  resolveNlQuery,
11708
+ resolvePrincipal,
11308
11709
  resolveSharingLevel,
11309
11710
  resolveTenant,
11310
11711
  revalidateAnonymized,
@@ -11324,6 +11725,7 @@ export {
11324
11725
  safetyHook,
11325
11726
  sanitizeUntrusted,
11326
11727
  sanitizeValue,
11728
+ scopeReads,
11327
11729
  scoreTopology,
11328
11730
  securityRelevantChange,
11329
11731
  serializeConfig,