@thotischner/observability-mcp 3.7.0 → 3.8.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 (53) hide show
  1. package/dist/auth/policy/loader.js +1 -1
  2. package/dist/auth/rbac.d.ts +1 -1
  3. package/dist/auth/rbac.js +3 -1
  4. package/dist/auth/rbac.test.js +5 -3
  5. package/dist/conformance/inspect-e2e.test.d.ts +1 -0
  6. package/dist/conformance/inspect-e2e.test.js +104 -0
  7. package/dist/connectors/loader.js +30 -0
  8. package/dist/connectors/loader.test.js +11 -0
  9. package/dist/enterprise-gate.d.ts +28 -0
  10. package/dist/enterprise-gate.js +51 -0
  11. package/dist/enterprise-gate.test.js +21 -1
  12. package/dist/index.js +285 -6
  13. package/dist/inspect/enforcer.d.ts +19 -0
  14. package/dist/inspect/enforcer.js +69 -0
  15. package/dist/inspect/enforcer.test.d.ts +1 -0
  16. package/dist/inspect/enforcer.test.js +76 -0
  17. package/dist/inspect/graph.d.ts +33 -0
  18. package/dist/inspect/graph.js +0 -0
  19. package/dist/inspect/graph.test.d.ts +1 -0
  20. package/dist/inspect/graph.test.js +74 -0
  21. package/dist/inspect/index.d.ts +8 -0
  22. package/dist/inspect/index.js +13 -0
  23. package/dist/inspect/mode.d.ts +20 -0
  24. package/dist/inspect/mode.js +57 -0
  25. package/dist/inspect/mode.test.d.ts +1 -0
  26. package/dist/inspect/mode.test.js +53 -0
  27. package/dist/inspect/profile-store.d.ts +42 -0
  28. package/dist/inspect/profile-store.js +139 -0
  29. package/dist/inspect/profile-store.test.d.ts +1 -0
  30. package/dist/inspect/profile-store.test.js +82 -0
  31. package/dist/inspect/profile.d.ts +51 -0
  32. package/dist/inspect/profile.js +111 -0
  33. package/dist/inspect/profile.test.d.ts +1 -0
  34. package/dist/inspect/profile.test.js +96 -0
  35. package/dist/inspect/recorder.d.ts +42 -0
  36. package/dist/inspect/recorder.js +72 -0
  37. package/dist/inspect/recorder.test.d.ts +1 -0
  38. package/dist/inspect/recorder.test.js +112 -0
  39. package/dist/inspect/signature.d.ts +32 -0
  40. package/dist/inspect/signature.js +200 -0
  41. package/dist/inspect/signature.test.d.ts +1 -0
  42. package/dist/inspect/signature.test.js +136 -0
  43. package/dist/inspect/store.d.ts +62 -0
  44. package/dist/inspect/store.js +76 -0
  45. package/dist/inspect/store.test.d.ts +1 -0
  46. package/dist/inspect/store.test.js +78 -0
  47. package/dist/metrics/self.d.ts +3 -0
  48. package/dist/metrics/self.js +19 -0
  49. package/dist/tenancy/context.d.ts +7 -0
  50. package/dist/tenancy/context.js +15 -0
  51. package/dist/tenancy/context.test.js +18 -1
  52. package/dist/ui/index.html +742 -0
  53. package/package.json +3 -2
@@ -0,0 +1,13 @@
1
+ // Inspect — observe / learn / enforce for MCP tool calls.
2
+ //
3
+ // Barrel for the inspection subsystem. Phase 1 ships the observe core
4
+ // (recorder + store + signature + mode + flow-graph aggregation); profile
5
+ // derivation and dry-run/enforce decisioning land in later phases.
6
+ export * from "./signature.js";
7
+ export * from "./store.js";
8
+ export * from "./mode.js";
9
+ export * from "./graph.js";
10
+ export * from "./recorder.js";
11
+ export * from "./enforcer.js";
12
+ export * from "./profile.js";
13
+ export * from "./profile-store.js";
@@ -0,0 +1,20 @@
1
+ export type InspectMode = "off" | "observe" | "dryrun" | "enforce";
2
+ export declare const INSPECT_MODES: readonly InspectMode[];
3
+ /** Parse a user/env string into a mode, or null when unrecognised. */
4
+ export declare function parseMode(s: unknown): InspectMode | null;
5
+ /** Resolve the boot mode from an env value, defaulting to `observe`. */
6
+ export declare function bootMode(envValue: unknown): InspectMode;
7
+ export declare class ModeController {
8
+ private mode;
9
+ private readonly onChange?;
10
+ constructor(initial?: InspectMode, onChange?: (m: InspectMode) => void);
11
+ get(): InspectMode;
12
+ /** Set the mode. Returns the resolved mode; throws on an invalid value. */
13
+ set(next: unknown): InspectMode;
14
+ /** True when the recorder should capture observations. */
15
+ get recording(): boolean;
16
+ /** True when calls should be evaluated against the profile. */
17
+ get evaluating(): boolean;
18
+ /** True when a deviation should actually be blocked. */
19
+ get blocking(): boolean;
20
+ }
@@ -0,0 +1,57 @@
1
+ // Inspect — mode state machine.
2
+ //
3
+ // off → observe → dryrun → enforce. `observe` is the default and is purely a
4
+ // recorder (zero decision on the call path). `dryrun` and `enforce` add a
5
+ // profile evaluation; only `enforce` ever blocks. The boot default comes from
6
+ // OMCP_INSPECT; the mode can be changed at runtime via the API.
7
+ export const INSPECT_MODES = ["off", "observe", "dryrun", "enforce"];
8
+ /** Parse a user/env string into a mode, or null when unrecognised. */
9
+ export function parseMode(s) {
10
+ if (typeof s !== "string")
11
+ return null;
12
+ const v = s.trim().toLowerCase();
13
+ // Friendly aliases.
14
+ if (v === "complain")
15
+ return "dryrun";
16
+ if (v === "dry-run")
17
+ return "dryrun";
18
+ if (v === "on")
19
+ return "observe";
20
+ return INSPECT_MODES.includes(v) ? v : null;
21
+ }
22
+ /** Resolve the boot mode from an env value, defaulting to `observe`. */
23
+ export function bootMode(envValue) {
24
+ return parseMode(envValue) ?? "observe";
25
+ }
26
+ export class ModeController {
27
+ mode;
28
+ onChange;
29
+ constructor(initial = "observe", onChange) {
30
+ this.mode = initial;
31
+ this.onChange = onChange;
32
+ }
33
+ get() {
34
+ return this.mode;
35
+ }
36
+ /** Set the mode. Returns the resolved mode; throws on an invalid value. */
37
+ set(next) {
38
+ const m = parseMode(next);
39
+ if (!m)
40
+ throw new Error(`invalid inspect mode '${String(next)}' (allowed: ${INSPECT_MODES.join(", ")})`);
41
+ this.mode = m;
42
+ this.onChange?.(m);
43
+ return m;
44
+ }
45
+ /** True when the recorder should capture observations. */
46
+ get recording() {
47
+ return this.mode !== "off";
48
+ }
49
+ /** True when calls should be evaluated against the profile. */
50
+ get evaluating() {
51
+ return this.mode === "dryrun" || this.mode === "enforce";
52
+ }
53
+ /** True when a deviation should actually be blocked. */
54
+ get blocking() {
55
+ return this.mode === "enforce";
56
+ }
57
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ModeController, parseMode, bootMode } from "./mode.js";
4
+ describe("parseMode / bootMode", () => {
5
+ it("accepts canonical modes + friendly aliases", () => {
6
+ assert.equal(parseMode("off"), "off");
7
+ assert.equal(parseMode("observe"), "observe");
8
+ assert.equal(parseMode("dryrun"), "dryrun");
9
+ assert.equal(parseMode("enforce"), "enforce");
10
+ assert.equal(parseMode("DRY-RUN"), "dryrun");
11
+ assert.equal(parseMode("complain"), "dryrun");
12
+ assert.equal(parseMode("on"), "observe");
13
+ });
14
+ it("rejects junk", () => {
15
+ assert.equal(parseMode("nope"), null);
16
+ assert.equal(parseMode(123), null);
17
+ assert.equal(parseMode(undefined), null);
18
+ });
19
+ it("bootMode defaults to observe", () => {
20
+ assert.equal(bootMode(undefined), "observe");
21
+ assert.equal(bootMode("enforce"), "enforce");
22
+ assert.equal(bootMode("garbage"), "observe");
23
+ });
24
+ });
25
+ describe("ModeController", () => {
26
+ it("exposes recording/evaluating/blocking per mode", () => {
27
+ const off = new ModeController("off");
28
+ assert.equal(off.recording, false);
29
+ assert.equal(off.evaluating, false);
30
+ assert.equal(off.blocking, false);
31
+ const observe = new ModeController("observe");
32
+ assert.equal(observe.recording, true);
33
+ assert.equal(observe.evaluating, false);
34
+ assert.equal(observe.blocking, false);
35
+ const dry = new ModeController("dryrun");
36
+ assert.equal(dry.recording, true);
37
+ assert.equal(dry.evaluating, true);
38
+ assert.equal(dry.blocking, false);
39
+ const enf = new ModeController("enforce");
40
+ assert.equal(enf.recording, true);
41
+ assert.equal(enf.evaluating, true);
42
+ assert.equal(enf.blocking, true);
43
+ });
44
+ it("set() validates and fires onChange", () => {
45
+ const seen = [];
46
+ const m = new ModeController("observe", (x) => seen.push(x));
47
+ assert.equal(m.set("enforce"), "enforce");
48
+ assert.equal(m.get(), "enforce");
49
+ assert.deepEqual(seen, ["enforce"]);
50
+ assert.throws(() => m.set("bogus"), /invalid inspect mode/);
51
+ assert.equal(m.get(), "enforce"); // unchanged after a bad set
52
+ });
53
+ });
@@ -0,0 +1,42 @@
1
+ import type { Observation } from "./store.js";
2
+ import { type CallSignature, type EvalResult, type ProfileRule, type RuleStatus } from "./profile.js";
3
+ export interface ProfileStoreOptions {
4
+ file?: string;
5
+ /** Seam: initial rules (tests). */
6
+ rules?: ProfileRule[];
7
+ /** Seams for tests. */
8
+ reader?: (file: string) => string;
9
+ writer?: (file: string, data: string) => void;
10
+ now?: () => number;
11
+ }
12
+ export declare class ProfileStore {
13
+ private rules;
14
+ private readonly file?;
15
+ private readonly reader;
16
+ private readonly writer;
17
+ private readonly now;
18
+ constructor(opts?: ProfileStoreOptions);
19
+ private load;
20
+ private persist;
21
+ list(): ProfileRule[];
22
+ suggested(): ProfileRule[];
23
+ accepted(): ProfileRule[];
24
+ /** Derive suggestions from observations, merge, persist; return all rules. */
25
+ derive(observations: Observation[]): ProfileRule[];
26
+ /** Accept/reject/reset a rule by id. Returns the updated rule or null. */
27
+ setStatus(id: string, status: RuleStatus): ProfileRule | null;
28
+ /** Replace a rule's constraints (manual edit). Returns updated rule or null. */
29
+ update(id: string, patch: Partial<Pick<ProfileRule, "constraints" | "subject">>): ProfileRule | null;
30
+ remove(id: string): boolean;
31
+ /**
32
+ * Absorb a single (deviating) call into the profile: widen the accepted rule
33
+ * for (subject, tool) to include this call's resource values + arg buckets,
34
+ * creating a tight accepted rule if none exists. Makes exactly that observed
35
+ * shape allowed — the "add this deviation to the profile" one-click. Returns
36
+ * the upserted rule.
37
+ */
38
+ absorb(call: CallSignature): ProfileRule;
39
+ evaluate(call: CallSignature): EvalResult;
40
+ get size(): number;
41
+ get persisted(): boolean;
42
+ }
@@ -0,0 +1,139 @@
1
+ // Inspect — profile persistence + CRUD.
2
+ //
3
+ // Holds the rule set, derives suggestions from observations, lets a reviewer
4
+ // accept/reject, and evaluates calls against the accepted rules. Persists to
5
+ // OMCP_INSPECT_PROFILE_FILE (JSON) when configured; in-memory otherwise.
6
+ // Reads/writes are best-effort and never throw into the call path.
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { deriveProfile, evaluateCall, ruleId, } from "./profile.js";
9
+ export class ProfileStore {
10
+ rules = [];
11
+ file;
12
+ reader;
13
+ writer;
14
+ now;
15
+ constructor(opts = {}) {
16
+ this.file = opts.file;
17
+ this.reader = opts.reader ?? ((f) => readFileSync(f, "utf8"));
18
+ this.writer = opts.writer ?? ((f, d) => writeFileSync(f, d));
19
+ this.now = opts.now ?? (() => Date.now());
20
+ if (opts.rules)
21
+ this.rules = opts.rules;
22
+ else if (this.file)
23
+ this.load();
24
+ }
25
+ load() {
26
+ if (!this.file)
27
+ return;
28
+ try {
29
+ const parsed = JSON.parse(this.reader(this.file));
30
+ if (parsed && Array.isArray(parsed.rules))
31
+ this.rules = parsed.rules;
32
+ }
33
+ catch {
34
+ // Missing/invalid file → start empty; never fatal.
35
+ }
36
+ }
37
+ persist() {
38
+ if (!this.file)
39
+ return;
40
+ try {
41
+ this.writer(this.file, JSON.stringify({ rules: this.rules }, null, 2));
42
+ }
43
+ catch {
44
+ /* best-effort */
45
+ }
46
+ }
47
+ list() {
48
+ return [...this.rules];
49
+ }
50
+ suggested() {
51
+ return this.rules.filter((r) => r.status === "suggested");
52
+ }
53
+ accepted() {
54
+ return this.rules.filter((r) => r.status === "accepted");
55
+ }
56
+ /** Derive suggestions from observations, merge, persist; return all rules. */
57
+ derive(observations) {
58
+ this.rules = deriveProfile(observations, this.rules);
59
+ this.persist();
60
+ return this.list();
61
+ }
62
+ /** Accept/reject/reset a rule by id. Returns the updated rule or null. */
63
+ setStatus(id, status) {
64
+ const r = this.rules.find((x) => x.id === id);
65
+ if (!r)
66
+ return null;
67
+ r.status = status;
68
+ this.persist();
69
+ return r;
70
+ }
71
+ /** Replace a rule's constraints (manual edit). Returns updated rule or null. */
72
+ update(id, patch) {
73
+ const r = this.rules.find((x) => x.id === id);
74
+ if (!r)
75
+ return null;
76
+ if (patch.constraints)
77
+ r.constraints = patch.constraints;
78
+ if (patch.subject)
79
+ r.subject = patch.subject;
80
+ this.persist();
81
+ return r;
82
+ }
83
+ remove(id) {
84
+ const before = this.rules.length;
85
+ this.rules = this.rules.filter((x) => x.id !== id);
86
+ if (this.rules.length !== before) {
87
+ this.persist();
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+ /**
93
+ * Absorb a single (deviating) call into the profile: widen the accepted rule
94
+ * for (subject, tool) to include this call's resource values + arg buckets,
95
+ * creating a tight accepted rule if none exists. Makes exactly that observed
96
+ * shape allowed — the "add this deviation to the profile" one-click. Returns
97
+ * the upserted rule.
98
+ */
99
+ absorb(call) {
100
+ const id = ruleId(call.principal, call.tool);
101
+ const ts = new Date(this.now()).toISOString();
102
+ let r = this.rules.find((x) => x.id === id);
103
+ if (!r) {
104
+ r = { id, subject: call.principal, tool: call.tool, constraints: {}, status: "accepted", provenance: { learnedFrom: 1, firstSeen: ts, lastSeen: ts } };
105
+ this.rules.push(r);
106
+ }
107
+ else {
108
+ r.status = "accepted";
109
+ r.provenance.lastSeen = ts;
110
+ }
111
+ const add = (arr, v) => {
112
+ const a = arr ?? [];
113
+ if (!a.includes(v))
114
+ a.push(v);
115
+ a.sort();
116
+ return a;
117
+ };
118
+ for (const dim of ["source", "service", "namespace"]) {
119
+ const v = call[dim];
120
+ if (v != null)
121
+ r.constraints[dim] = add(r.constraints[dim], v);
122
+ }
123
+ for (const [k, b] of Object.entries(call.argShape || {})) {
124
+ r.constraints.argShape = r.constraints.argShape ?? {};
125
+ r.constraints.argShape[k] = add(r.constraints.argShape[k], b);
126
+ }
127
+ this.persist();
128
+ return r;
129
+ }
130
+ evaluate(call) {
131
+ return evaluateCall(call, this.rules);
132
+ }
133
+ get size() {
134
+ return this.rules.length;
135
+ }
136
+ get persisted() {
137
+ return !!this.file;
138
+ }
139
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ProfileStore } from "./profile-store.js";
4
+ import { ruleId } from "./profile.js";
5
+ let seq = 0;
6
+ function obs(over = {}) {
7
+ return {
8
+ ts: new Date(1_700_000_000_000 + seq * 1000).toISOString(),
9
+ seq: ++seq,
10
+ principal: "key:bot", auth: "apikey", tenant: "default", tool: "query_logs",
11
+ argShape: {}, outcome: "ok", decision: "allow", redactions: 0, ...over,
12
+ };
13
+ }
14
+ describe("ProfileStore", () => {
15
+ it("derive → setStatus(accept) → evaluate allows learned, flags novel", () => {
16
+ const s = new ProfileStore();
17
+ s.derive([obs({ principal: "a", tool: "query_logs", service: "pay", argShape: { window: "<=1h" } })]);
18
+ assert.equal(s.suggested().length, 1);
19
+ assert.equal(s.accepted().length, 0);
20
+ const id = ruleId("a", "query_logs");
21
+ s.setStatus(id, "accepted");
22
+ assert.equal(s.accepted().length, 1);
23
+ assert.equal(s.evaluate({ principal: "a", tool: "query_logs", service: "pay", argShape: { window: "<=1h" } }).verdict, "allow");
24
+ assert.equal(s.evaluate({ principal: "a", tool: "query_logs", service: "x", argShape: {} }).kind, "new-resource");
25
+ });
26
+ it("setStatus/remove return null/false for unknown ids", () => {
27
+ const s = new ProfileStore();
28
+ assert.equal(s.setStatus("nope", "accepted"), null);
29
+ assert.equal(s.remove("nope"), false);
30
+ });
31
+ it("update replaces constraints", () => {
32
+ const s = new ProfileStore();
33
+ s.derive([obs({ principal: "a", tool: "t", service: "s1" })]);
34
+ const id = ruleId("a", "t");
35
+ s.update(id, { constraints: { service: ["s1", "s2"] } });
36
+ assert.deepEqual(s.list().find((r) => r.id === id).constraints.service, ["s1", "s2"]);
37
+ });
38
+ it("persists via the writer seam and reloads via the reader seam", () => {
39
+ let blob = "";
40
+ const a = new ProfileStore({ file: "profile-store-test.json", reader: () => { throw new Error("absent"); }, writer: (_f, d) => { blob = d; } });
41
+ a.derive([obs({ principal: "a", tool: "t", service: "s" })]);
42
+ a.setStatus(ruleId("a", "t"), "accepted");
43
+ assert.ok(blob.includes('"accepted"'));
44
+ // a fresh store reads it back
45
+ const b = new ProfileStore({ file: "profile-store-test.json", reader: () => blob, writer: () => { } });
46
+ assert.equal(b.accepted().length, 1);
47
+ assert.equal(b.persisted, true);
48
+ });
49
+ it("absorb: creates a tight accepted rule for a brand-new deviation", () => {
50
+ const s = new ProfileStore();
51
+ const r = s.absorb({ principal: "mallory", tool: "query_logs", service: "pay", argShape: { window: "<=1h" } });
52
+ assert.equal(r.status, "accepted");
53
+ assert.deepEqual(r.constraints.service, ["pay"]);
54
+ assert.deepEqual(r.constraints.argShape.window, ["<=1h"]);
55
+ // that exact call is now allowed
56
+ assert.equal(s.evaluate({ principal: "mallory", tool: "query_logs", service: "pay", argShape: { window: "<=1h" } }).verdict, "allow");
57
+ });
58
+ it("absorb: widens an existing accepted rule (sorted union, no dupes)", () => {
59
+ const s = new ProfileStore();
60
+ s.absorb({ principal: "a", tool: "t", service: "s1", argShape: {} });
61
+ const r = s.absorb({ principal: "a", tool: "t", service: "s2", argShape: {} });
62
+ assert.deepEqual(r.constraints.service, ["s1", "s2"]);
63
+ // idempotent
64
+ const r2 = s.absorb({ principal: "a", tool: "t", service: "s2", argShape: {} });
65
+ assert.deepEqual(r2.constraints.service, ["s1", "s2"]);
66
+ assert.equal(s.list().filter((x) => x.id === ruleId("a", "t")).length, 1);
67
+ });
68
+ it("absorb: flips a previously-rejected/suggested rule to accepted", () => {
69
+ const s = new ProfileStore();
70
+ s.derive([obs({ principal: "a", tool: "t", service: "s1" })]); // suggested
71
+ s.setStatus(ruleId("a", "t"), "rejected");
72
+ const r = s.absorb({ principal: "a", tool: "t", service: "s9", argShape: {} });
73
+ assert.equal(r.status, "accepted");
74
+ assert.deepEqual(r.constraints.service, ["s1", "s9"]);
75
+ });
76
+ it("a missing/invalid file starts empty, never throws", () => {
77
+ assert.doesNotThrow(() => {
78
+ const s = new ProfileStore({ file: "profile-store-missing.json", reader: () => "not json{", writer: () => { } });
79
+ assert.equal(s.size, 0);
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,51 @@
1
+ import type { Observation } from "./store.js";
2
+ export type RuleStatus = "suggested" | "accepted" | "rejected";
3
+ export type DeviationKind = "new-principal" | "new-tool" | "new-resource" | "arg-out-of-range";
4
+ export interface RuleConstraints {
5
+ source?: string[];
6
+ service?: string[];
7
+ namespace?: string[];
8
+ /** Per arg key → the set of buckets seen during learning. */
9
+ argShape?: Record<string, string[]>;
10
+ }
11
+ export interface ProfileRule {
12
+ id: string;
13
+ subject: string;
14
+ tool: string;
15
+ constraints: RuleConstraints;
16
+ status: RuleStatus;
17
+ provenance: {
18
+ learnedFrom: number;
19
+ firstSeen: string;
20
+ lastSeen: string;
21
+ };
22
+ }
23
+ export interface EvalResult {
24
+ verdict: "allow" | "deviation";
25
+ kind?: DeviationKind;
26
+ ruleId?: string;
27
+ detail?: string;
28
+ }
29
+ /** Stable per (subject, tool) id so derive() is idempotent. */
30
+ export declare function ruleId(subject: string, tool: string): string;
31
+ /**
32
+ * Derive suggested rules from observations, merged onto an existing rule set.
33
+ * Returns the FULL updated rule list. Accepted/rejected rules are preserved;
34
+ * suggested rules are refreshed from the latest traffic.
35
+ */
36
+ export declare function deriveProfile(observations: Observation[], existing?: ProfileRule[]): ProfileRule[];
37
+ /** A minimal call shape evaluate() needs (a subset of an Observation). */
38
+ export interface CallSignature {
39
+ principal: string;
40
+ tool: string;
41
+ source?: string;
42
+ service?: string;
43
+ namespace?: string;
44
+ argShape: Record<string, string>;
45
+ }
46
+ /**
47
+ * Evaluate a call against the ACCEPTED rules of a profile. Returns allow when
48
+ * an accepted rule covers it, else a deviation classified by kind. Suggested /
49
+ * rejected rules are ignored.
50
+ */
51
+ export declare function evaluateCall(call: CallSignature, rules: ProfileRule[]): EvalResult;
@@ -0,0 +1,111 @@
1
+ // Inspect — behavior profile (the AppArmor-style learned ruleset).
2
+ //
3
+ // A profile is a set of rules, one per (subject, tool). Each rule is *derived*
4
+ // from observed traffic (the union of resource dimensions + argument-shape
5
+ // buckets seen for that subject+tool), lands as `suggested`, and a human
6
+ // accepts / rejects it. Only `accepted` rules are consulted by evaluate() in
7
+ // dry-run / enforce — `suggested` rules never block anything.
8
+ //
9
+ // derive() is idempotent: re-running refreshes `suggested` rules from fresh
10
+ // traffic but never mutates a human's accepted/rejected decision, and never
11
+ // resurrects a rejected rule.
12
+ /** Stable per (subject, tool) id so derive() is idempotent. */
13
+ export function ruleId(subject, tool) {
14
+ return subject + "::" + tool;
15
+ }
16
+ const RES_DIMS = ["source", "service", "namespace"];
17
+ function uniqSorted(xs) {
18
+ return [...new Set(xs)].sort();
19
+ }
20
+ /** Build a constraint set from a group of observations. */
21
+ function constraintsFrom(group) {
22
+ const c = {};
23
+ for (const dim of RES_DIMS) {
24
+ const vals = group.map((o) => o[dim]).filter((v) => typeof v === "string" && v.length > 0);
25
+ if (vals.length)
26
+ c[dim] = uniqSorted(vals);
27
+ }
28
+ const argKeys = new Set();
29
+ group.forEach((o) => Object.keys(o.argShape || {}).forEach((k) => argKeys.add(k)));
30
+ if (argKeys.size) {
31
+ c.argShape = {};
32
+ for (const k of argKeys) {
33
+ const buckets = group.map((o) => o.argShape?.[k]).filter((v) => typeof v === "string");
34
+ c.argShape[k] = uniqSorted(buckets);
35
+ }
36
+ }
37
+ return c;
38
+ }
39
+ /**
40
+ * Derive suggested rules from observations, merged onto an existing rule set.
41
+ * Returns the FULL updated rule list. Accepted/rejected rules are preserved;
42
+ * suggested rules are refreshed from the latest traffic.
43
+ */
44
+ export function deriveProfile(observations, existing = []) {
45
+ const byId = new Map();
46
+ for (const r of existing)
47
+ byId.set(r.id, r);
48
+ // Group observations by (subject, tool).
49
+ const groups = new Map();
50
+ for (const o of observations) {
51
+ const id = ruleId(o.principal, o.tool);
52
+ const g = groups.get(id);
53
+ if (g)
54
+ g.push(o);
55
+ else
56
+ groups.set(id, [o]);
57
+ }
58
+ for (const [id, group] of groups) {
59
+ const existingRule = byId.get(id);
60
+ if (existingRule && existingRule.status !== "suggested")
61
+ continue; // never touch a human decision
62
+ const sorted = [...group].sort((a, b) => a.ts.localeCompare(b.ts));
63
+ byId.set(id, {
64
+ id,
65
+ subject: group[0].principal,
66
+ tool: group[0].tool,
67
+ constraints: constraintsFrom(group),
68
+ status: "suggested",
69
+ provenance: {
70
+ learnedFrom: group.length,
71
+ firstSeen: sorted[0].ts,
72
+ lastSeen: sorted[sorted.length - 1].ts,
73
+ },
74
+ });
75
+ }
76
+ return [...byId.values()];
77
+ }
78
+ /**
79
+ * Evaluate a call against the ACCEPTED rules of a profile. Returns allow when
80
+ * an accepted rule covers it, else a deviation classified by kind. Suggested /
81
+ * rejected rules are ignored.
82
+ */
83
+ export function evaluateCall(call, rules) {
84
+ const accepted = rules.filter((r) => r.status === "accepted");
85
+ const subjectRules = accepted.filter((r) => r.subject === call.principal || r.subject === "*");
86
+ if (subjectRules.length === 0) {
87
+ // Has the subject any accepted rule at all? (Distinguishes a wholly-new
88
+ // principal from a known principal reaching for a new tool.)
89
+ const known = accepted.some((r) => r.subject === call.principal);
90
+ return { verdict: "deviation", kind: known ? "new-tool" : "new-principal" };
91
+ }
92
+ const rule = subjectRules.find((r) => r.tool === call.tool);
93
+ if (!rule)
94
+ return { verdict: "deviation", kind: "new-tool" };
95
+ for (const dim of RES_DIMS) {
96
+ const v = call[dim];
97
+ if (v == null)
98
+ continue;
99
+ const allowed = rule.constraints[dim];
100
+ if (!allowed || !allowed.includes(v)) {
101
+ return { verdict: "deviation", kind: "new-resource", ruleId: rule.id, detail: `${dim}=${v}` };
102
+ }
103
+ }
104
+ for (const [k, bucket] of Object.entries(call.argShape || {})) {
105
+ const allowed = rule.constraints.argShape?.[k];
106
+ if (!allowed || !allowed.includes(bucket)) {
107
+ return { verdict: "deviation", kind: "arg-out-of-range", ruleId: rule.id, detail: `${k}=${bucket}` };
108
+ }
109
+ }
110
+ return { verdict: "allow", ruleId: rule.id };
111
+ }
@@ -0,0 +1 @@
1
+ export {};