@i4ctime/q-ring 0.17.0 → 0.17.6

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/README.md CHANGED
@@ -355,6 +355,8 @@ qring push cloudflare
355
355
  qring push github --keys DATABASE_URL,API_KEY --dry-run
356
356
  ```
357
357
 
358
+ Canaries can ride along: `qring canary plant KEY --format aws --push github` plants a honeytoken locally and seeds it into the platform without reading it back (see Canary Honeytokens).
359
+
358
360
  ### Secret Liveness Validation
359
361
 
360
362
  Test if a secret is actually valid with its target service. q-ring auto-detects the provider from key prefixes (`sk-` → OpenAI, `ghp_` → GitHub, etc.) or accepts an explicit provider name.
@@ -508,21 +510,43 @@ Plant fake credentials that look and read exactly like real ones. Anything that
508
510
  # Plant a canary shaped like a real AWS access key
509
511
  qring canary plant AWS_SECRET_ACCESS_KEY --format aws
510
512
 
511
- # Other shapes: github, openai, anthropic, stripe, generic
512
- qring canary plant GHP_BACKUP_TOKEN --format github
513
+ # Other shapes: aws-secret, github, github-pat, openai, openai-project,
514
+ # anthropic, stripe, gitlab, slack, google, npm, generic
515
+ qring canary plant GHP_BACKUP_TOKEN --format github-pat
513
516
 
514
517
  # See what's been tripped
515
518
  qring canary list
516
519
  qring audit --action canary
517
520
  ```
518
521
 
519
- Values are CSPRNG noise in the provider's real token shape (an `aws` canary matches `AKIA[A-Z0-9]{16}`) — plausible enough to be taken, never valid. Alerts are throttled to one per key per 30 seconds; the audit trail records every read.
522
+ Values are CSPRNG noise in the provider's real token shape (an `aws` canary matches `AKIA[A-Z0-9]{16}`, an `anthropic` one the real `sk-ant-api03-…AA` layout) — plausible enough to be taken, never valid. Alerts are throttled to one per key per 30 seconds; the audit trail records every read.
523
+
524
+ **Get paged.** Desktop notifications only help when you are at the machine. Register webhook channels and every trip reaches them too:
525
+
526
+ ```bash
527
+ qring canary alert add --discord https://discord.com/api/webhooks/…
528
+ qring canary alert add --slack https://hooks.slack.com/services/…
529
+ qring canary alert add --ntfy https://ntfy.sh/my-canaries
530
+ qring canary alert add --url https://example.com/canary # generic JSON POST
531
+ qring canary alert list
532
+ qring canary alert test # send a clearly-labelled drill
533
+ ```
534
+
535
+ Channels live in `~/.config/q-ring/canary-alerts.json` (mode `0600`). Sends are fire-and-forget, SSRF-guarded like hooks, throttled with the desktop alert, and never include the fake value — only the key, scope, source, and the agent label that reached for it.
536
+
537
+ **Seed a tripwire into CI.** Plant a canary and push it to a deployment platform in one step, so a leaked GitHub Actions / Vercel / Cloudflare environment carries a decoy:
538
+
539
+ ```bash
540
+ qring canary plant AWS_SECRET_ACCESS_KEY --format aws-secret --push github --repo you/your-app
541
+ ```
542
+
543
+ Honest caveat: q-ring only sees reads that go through q-ring. A leaked value used directly on the platform side is not observable here — pair it with the provider's own alerting if you need that.
520
544
 
521
545
  Canaries are built to stay covert: they carry no identifying description (add an innocuous cover story with `--description` if you like), their flag never appears in MCP tool responses, and trip records are visible only from the operator's terminal — never to agents via MCP audit tools. Bulk `export` and `delete` trip them just like reads, so sweeping the ring or removing the tripwire both ring the bell. Done with one? `qring canary disarm <key>` turns it back into an ordinary secret (`qring set` over a canary warns you first — the flag deliberately survives overwrites so an agent can't launder it away).
522
546
 
523
547
  ### MCP Airlock
524
548
 
525
- Run a third-party MCP server behind q-ring. The airlock sits between your agent host and the wrapped server, spawns it with a **stripped environment** (no inherited API keys — opt back in with `--inherit-env`), and records every tool call that crosses it as a `wrap` event in the audit chain, grouped per session and labeled with the calling client's identity. Tool arguments are never logged — they may contain secrets.
549
+ Run a third-party MCP server behind q-ring. The airlock sits between your agent host and the wrapped server, spawns it with a **stripped environment** (no inherited API keys — opt back in with `--inherit-env`), records every tool call, resource read, and prompt fetch that crosses it as a `wrap` event in the audit chain (grouped per session and labeled with the calling client's identity), **scrubs known secret values out of every result** before it reaches the transcript, and enforces the project's `policy.wrap` rules. Tool and prompt arguments are never logged — they may contain secrets.
526
550
 
527
551
  ```json
528
552
  {
@@ -535,9 +559,42 @@ Run a third-party MCP server behind q-ring. The airlock sits between your agent
535
559
  }
536
560
  ```
537
561
 
538
- Tools-only proxy today: `tools/list` and `tools/call` pass through verbatim (pagination, progress notifications, cancellation, and `tools/list_changed` included; long-running tools are governed by the host's own timeout, with a generous airlock ceiling configurable via `QRING_WRAP_TIMEOUT_MS`). A wrapped server's *resources and prompts* are not proxied yet — a resources-heavy server will look tools-only behind the airlock.
562
+ Tools, resources, and prompts all pass through verbatim (pagination, progress notifications, cancellation, subscriptions, and the `list_changed` / `updated` notifications included); the airlock advertises exactly the capabilities the wrapped server has. Long-running tools are governed by the host's own timeout, with a generous airlock ceiling configurable via `QRING_WRAP_TIMEOUT_MS`.
563
+
564
+ ```bash
565
+ # Wrap a remote Streamable HTTP server; the Bearer token comes from q-ring (audited read)
566
+ qring mcp wrap --url https://mcp.example.com/mcp --auth-secret EXAMPLE_MCP_TOKEN
567
+
568
+ # Keep results verbatim (default: known secret values are replaced with [QRING:REDACTED])
569
+ qring mcp wrap --no-redact -- npx -y some-mcp-server
570
+ ```
571
+
572
+ **Wrap policy.** Govern the wrapped server from `.q-ring.json` — the same file, the same fail-closed engine:
573
+
574
+ ```json
575
+ {
576
+ "policy": {
577
+ "wrap": {
578
+ "allowTools": ["github_*", "search"],
579
+ "denyTools": ["github_delete_*"],
580
+ "approveTools": ["github_merge_pr"],
581
+ "rateLimit": { "maxCalls": 60, "perSeconds": 60 },
582
+ "toolRateLimits": { "search": { "maxCalls": 5, "perSeconds": 10 } },
583
+ "redactResults": true
584
+ }
585
+ }
586
+ }
587
+ ```
588
+
589
+ Denied tools are hidden from `tools/list` and refused on call with a `policy_deny` audit event; `approveTools` are refused until you grant them:
539
590
 
540
- Be clear about what the airlock is: env stripping plus a tamper-evident record of every tool call. It is **not a sandbox** — the wrapped process still runs as your user with normal filesystem, network, and OS-keychain access, and tool descriptions/results pass through uninspected. See `docs/threat-model.md` for the honest boundary picture.
591
+ ```bash
592
+ qring mcp approve github_merge_pr --for 900 --reason "release 1.4"
593
+ qring mcp approvals
594
+ qring mcp approve github_merge_pr --revoke
595
+ ```
596
+
597
+ Be clear about what the airlock is: env stripping, a tamper-evident record of every crossing, policy at the tool boundary, and best-effort redaction of secret *values* the ring knows about. It is **not a sandbox** — the wrapped process still runs as your user with normal filesystem, network, and OS-keychain access, and tool descriptions pass through uninspected. See `docs/threat-model.md` for the honest boundary picture.
541
598
 
542
599
  ### Just-In-Time (JIT) Provisioning
543
600
 
@@ -632,7 +689,7 @@ qring wizard myservice --hook-exec "pm2 restart app"
632
689
 
633
690
  ### Governance Policy
634
691
 
635
- Define project-level governance rules in `.q-ring.json` to control which MCP tools can be used, which keys are accessible, and which commands can be executed. Policy is enforced at both the MCP server and keyring level.
692
+ Define project-level governance rules in `.q-ring.json` to control which MCP tools can be used, which keys are accessible, which commands can be executed, and what a wrapped MCP server may do behind the airlock. Policy is enforced at the MCP server, keyring, and airlock level.
636
693
 
637
694
  Over MCP, policy is resolved from the directory the server was **launched** in — not from the `projectPath` a caller passes — so an agent can't sidestep restrictions by pointing at a directory with no policy. Launch the MCP server from your project root (where `.q-ring.json` lives). Edits to `.q-ring.json` are picked up automatically (the policy cache invalidates on file change), so you don't need to restart the server.
638
695
 
@@ -663,6 +720,11 @@ Example policy in `.q-ring.json`:
663
720
  "secrets": {
664
721
  "requireApprovalForTags": ["production"],
665
722
  "maxTtlSeconds": 86400
723
+ },
724
+ "wrap": {
725
+ "denyTools": ["*_delete_*"],
726
+ "approveTools": ["deploy_*"],
727
+ "rateLimit": { "maxCalls": 60, "perSeconds": 60 }
666
728
  }
667
729
  }
668
730
  }
@@ -700,6 +762,25 @@ qring audit:export --format json --since 2026-03-01
700
762
  qring audit:export --format csv --output audit-report.csv
701
763
  ```
702
764
 
765
+ ### Agent Session Timeline
766
+
767
+ Every MCP session is stamped with the client's identity (`clientInfo` name@version) and every airlock session with a correlation id. `audit:sessions` folds the flat audit feed back into one timeline per agent process, so "what did Cursor do in that session?" is one command instead of a grep.
768
+
769
+ ```bash
770
+ # One block per session: agent, source, window, event + denial counts, keys touched
771
+ qring audit:sessions
772
+
773
+ # Narrow to one client, widen the window, print every event line
774
+ qring audit:sessions --agent "Cursor@1.2.3" --since 7d --verbose
775
+
776
+ # Machine-readable
777
+ qring audit:sessions --json
778
+ ```
779
+
780
+ Airlock sessions (`qring mcp wrap`) show up as `airlock: <wrapped command>` with every proxied call in order. The status dashboard (`qring status`) renders the same data as an expandable **Agent Sessions (24h)** card.
781
+
782
+ Agents can look at their own history too — as MCP **resources**, not tools: `qring://sessions` lists session summaries and `qring://sessions/{id}` returns one timeline (key names and actions only, never values). Two guarantees hold on the agent side: canary trips are stripped before anything is summarised, so a honeytoken can never be discovered from a session view, and denying the `audit_log` tool in `.q-ring.json` policy hides the resources as well — one switch controls audit visibility for agents.
783
+
703
784
  ### Encrypted File Backend (Headless / CI)
704
785
 
705
786
  Hosts with no OS keyring at all (headless Linux, containers, CI) can opt into an encrypted file store. Everything — secrets, the audit anchor, the agent-memory key — routes through it.
@@ -780,6 +861,7 @@ What you get:
780
861
  - **Secrets table** — searchable, sortable view of every secret (key, scope, env, type, decay, tags, last read), with quick chips for `expired`, `stale`, and `protected` filters. Press `/` to focus the search box.
781
862
  - **Quantum cards** — decay timers, superposition states, entanglement pairs, and active quantum tunnels.
782
863
  - **Approvals & hooks** — live list of valid (and tampered) approval grants and every registered hook with its match summary.
864
+ - **Agent sessions (24h)** — one expandable row per MCP client or airlock session: agent label, source, window, event and denial counts, recent events.
783
865
  - **Agent memory** — count of encrypted memory keys persisted at `~/.config/q-ring/agent-memory.enc`.
784
866
  - **Anomaly alerts** — burst reads, off-hours access, tampered audit chain, and other suspicious patterns.
785
867
  - **Audit log (24h)** — filterable feed with action chips (`read`/`write`/`delete`/`export`), source chips (`cli`/`mcp`/`hook`/`agent`), and a free-text filter.
@@ -1569,10 +1569,23 @@ var secretsPolicySchema = z2.object({
1569
1569
  requireRotationFormatForTags: stringArray.optional(),
1570
1570
  maxTtlSeconds: z2.number().optional()
1571
1571
  }).strict();
1572
+ var rateLimitSchema = z2.object({
1573
+ maxCalls: z2.number().int().positive(),
1574
+ perSeconds: z2.number().positive()
1575
+ }).strict();
1576
+ var wrapPolicySchema = z2.object({
1577
+ allowTools: stringArray.optional(),
1578
+ denyTools: stringArray.optional(),
1579
+ approveTools: stringArray.optional(),
1580
+ rateLimit: rateLimitSchema.optional(),
1581
+ toolRateLimits: z2.record(z2.string(), rateLimitSchema).optional(),
1582
+ redactResults: z2.boolean().optional()
1583
+ }).strict();
1572
1584
  var policySchema = z2.object({
1573
1585
  mcp: mcpPolicySchema.optional(),
1574
1586
  exec: execPolicySchema.optional(),
1575
- secrets: secretsPolicySchema.optional()
1587
+ secrets: secretsPolicySchema.optional(),
1588
+ wrap: wrapPolicySchema.optional()
1576
1589
  }).strict();
1577
1590
  var PolicyConfigError = class extends Error {
1578
1591
  constructor(message) {
@@ -1582,6 +1595,10 @@ var PolicyConfigError = class extends Error {
1582
1595
  };
1583
1596
  var cachedPolicy = null;
1584
1597
  var policyRoot = null;
1598
+ function setPolicyRoot(root) {
1599
+ policyRoot = root;
1600
+ cachedPolicy = null;
1601
+ }
1585
1602
  function getPolicyRoot() {
1586
1603
  return policyRoot;
1587
1604
  }
@@ -1725,9 +1742,55 @@ function getPolicySummary(projectPath) {
1725
1742
  hasMcpPolicy: !!policy.mcp,
1726
1743
  hasExecPolicy: !!policy.exec,
1727
1744
  hasSecretPolicy: !!policy.secrets,
1745
+ hasWrapPolicy: !!policy.wrap,
1728
1746
  details: policy
1729
1747
  };
1730
1748
  }
1749
+ function matchesToolPattern(pattern, toolName) {
1750
+ if (!pattern.includes("*")) return pattern === toolName;
1751
+ const re = new RegExp(
1752
+ "^" + pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"
1753
+ );
1754
+ return re.test(toolName);
1755
+ }
1756
+ function anyPatternMatches(patterns, toolName) {
1757
+ return patterns?.find((p) => matchesToolPattern(p, toolName));
1758
+ }
1759
+ function checkWrapToolPolicy(toolName, projectPath) {
1760
+ const wrap = loadPolicy(projectPath).wrap;
1761
+ if (!wrap) return { allowed: true, policySource: "no-policy" };
1762
+ const denied = anyPatternMatches(wrap.denyTools, toolName);
1763
+ if (denied) {
1764
+ return {
1765
+ allowed: false,
1766
+ reason: `wrapped tool "${toolName}" is denied by policy (${denied})`,
1767
+ policySource: ".q-ring.json policy.wrap.denyTools"
1768
+ };
1769
+ }
1770
+ if (wrap.allowTools && !anyPatternMatches(wrap.allowTools, toolName)) {
1771
+ return {
1772
+ allowed: false,
1773
+ reason: `wrapped tool "${toolName}" is not in the allow list`,
1774
+ policySource: ".q-ring.json policy.wrap.allowTools"
1775
+ };
1776
+ }
1777
+ return { allowed: true, policySource: ".q-ring.json policy.wrap" };
1778
+ }
1779
+ function wrapToolRequiresApproval(toolName, projectPath) {
1780
+ const wrap = loadPolicy(projectPath).wrap;
1781
+ return !!anyPatternMatches(wrap?.approveTools, toolName);
1782
+ }
1783
+ function getWrapRateLimit(toolName, projectPath) {
1784
+ const wrap = loadPolicy(projectPath).wrap;
1785
+ if (!wrap) return void 0;
1786
+ const perTool = wrap.toolRateLimits ? Object.entries(wrap.toolRateLimits).find(
1787
+ ([pattern]) => matchesToolPattern(pattern, toolName)
1788
+ )?.[1] : void 0;
1789
+ return perTool ?? wrap.rateLimit;
1790
+ }
1791
+ function wrapRedactsResults(projectPath) {
1792
+ return loadPolicy(projectPath).wrap?.redactResults ?? true;
1793
+ }
1731
1794
 
1732
1795
  // src/utils/hash.ts
1733
1796
  import { createHash as createHash2 } from "crypto";
@@ -1829,6 +1892,180 @@ function notifyApprovalRequested(key, source) {
1829
1892
  );
1830
1893
  }
1831
1894
 
1895
+ // src/core/canary-webhooks.ts
1896
+ import { existsSync as existsSync7, writeFileSync as writeFileSync6, mkdirSync as mkdirSync7 } from "fs";
1897
+ import { join as join10 } from "path";
1898
+ import { homedir as homedir7 } from "os";
1899
+ import { randomUUID as randomUUID2 } from "crypto";
1900
+ var CANARY_ALERT_TYPES = ["discord", "slack", "ntfy", "generic"];
1901
+ function getRegistryPath4() {
1902
+ const override = process.env.QRING_CANARY_ALERTS_PATH;
1903
+ if (override) return override;
1904
+ const dir = join10(homedir7(), ".config", "q-ring");
1905
+ if (!existsSync7(dir)) mkdirSync7(dir, { recursive: true, mode: 448 });
1906
+ return join10(dir, "canary-alerts.json");
1907
+ }
1908
+ function loadRegistry4() {
1909
+ return loadJsonRegistry(getRegistryPath4(), { channels: [] });
1910
+ }
1911
+ function saveRegistry4(registry2) {
1912
+ writeFileSync6(getRegistryPath4(), JSON.stringify(registry2, null, 2), { mode: 384 });
1913
+ }
1914
+ function assertHttpUrl(url) {
1915
+ let parsed;
1916
+ try {
1917
+ parsed = new URL(url);
1918
+ } catch {
1919
+ throw new Error(`Invalid webhook URL: ${url}`);
1920
+ }
1921
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1922
+ throw new Error(`Webhook URL must be http(s): ${url}`);
1923
+ }
1924
+ }
1925
+ function addCanaryAlert(entry) {
1926
+ if (!CANARY_ALERT_TYPES.includes(entry.type)) {
1927
+ throw new Error(
1928
+ `Unknown alert type "${entry.type}". Available: ${CANARY_ALERT_TYPES.join(", ")}`
1929
+ );
1930
+ }
1931
+ assertHttpUrl(entry.url);
1932
+ const registry2 = loadRegistry4();
1933
+ const channel = {
1934
+ id: randomUUID2().slice(0, 8),
1935
+ type: entry.type,
1936
+ url: entry.url,
1937
+ description: entry.description,
1938
+ enabled: true,
1939
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1940
+ };
1941
+ registry2.channels.push(channel);
1942
+ saveRegistry4(registry2);
1943
+ return channel;
1944
+ }
1945
+ function listCanaryAlerts() {
1946
+ return loadRegistry4().channels;
1947
+ }
1948
+ function removeCanaryAlert(id) {
1949
+ const registry2 = loadRegistry4();
1950
+ const before = registry2.channels.length;
1951
+ registry2.channels = registry2.channels.filter((c) => c.id !== id);
1952
+ if (registry2.channels.length === before) return false;
1953
+ saveRegistry4(registry2);
1954
+ return true;
1955
+ }
1956
+ function setCanaryAlertEnabled(id, enabled) {
1957
+ const registry2 = loadRegistry4();
1958
+ const channel = registry2.channels.find((c) => c.id === id);
1959
+ if (!channel) return false;
1960
+ channel.enabled = enabled;
1961
+ saveRegistry4(registry2);
1962
+ return true;
1963
+ }
1964
+ function describeAlertUrl(url) {
1965
+ try {
1966
+ const u = new URL(url);
1967
+ const tail = u.pathname.length > 12 ? `${u.pathname.slice(0, 8)}\u2026${u.pathname.slice(-4)}` : u.pathname;
1968
+ return `${u.protocol}//${u.host}${tail}`;
1969
+ } catch {
1970
+ return url;
1971
+ }
1972
+ }
1973
+ function headline(event) {
1974
+ return event.test ? "q-ring canary alert test" : "q-ring: CANARY TRIPPED";
1975
+ }
1976
+ function summary(event) {
1977
+ const who = event.agent ? `${event.source} (${event.agent})` : event.source;
1978
+ const where = event.env ? `${event.scope}/${event.env}` : event.scope;
1979
+ return event.test ? `This is a test from \`qring canary alert test\`. Trips on honeytoken "${event.key}" (${where}) would arrive here.` : `Honeytoken "${event.key}" (${where}) was read by ${who} at ${event.timestamp}. ${event.detail}. This credential is fake \u2014 but something reached for it. Investigate: qring audit --action canary`;
1980
+ }
1981
+ function buildAlertPayload(type, event) {
1982
+ const json = { "Content-Type": "application/json", "User-Agent": "q-ring-canary/1.0" };
1983
+ switch (type) {
1984
+ case "discord":
1985
+ return {
1986
+ body: JSON.stringify({ content: `\u{1F6A8} **${headline(event)}**
1987
+ ${summary(event)}` }),
1988
+ headers: json
1989
+ };
1990
+ case "slack":
1991
+ return {
1992
+ body: JSON.stringify({ text: `:rotating_light: *${headline(event)}*
1993
+ ${summary(event)}` }),
1994
+ headers: json
1995
+ };
1996
+ case "ntfy":
1997
+ return {
1998
+ body: summary(event),
1999
+ headers: {
2000
+ "Content-Type": "text/plain",
2001
+ "User-Agent": "q-ring-canary/1.0",
2002
+ Title: headline(event),
2003
+ Priority: event.test ? "3" : "5",
2004
+ Tags: event.test ? "test_tube" : "rotating_light"
2005
+ }
2006
+ };
2007
+ case "generic":
2008
+ return {
2009
+ body: JSON.stringify({
2010
+ event: "canary",
2011
+ test: event.test ?? false,
2012
+ key: event.key,
2013
+ scope: event.scope,
2014
+ env: event.env,
2015
+ source: event.source,
2016
+ agent: event.agent ?? void 0,
2017
+ detail: event.detail,
2018
+ timestamp: event.timestamp
2019
+ }),
2020
+ headers: json
2021
+ };
2022
+ }
2023
+ }
2024
+ async function sendOne(channel, event) {
2025
+ const base = { channelId: channel.id, type: channel.type };
2026
+ const ssrfBlock = await checkSSRF(channel.url);
2027
+ if (ssrfBlock) {
2028
+ logAudit({
2029
+ action: "policy_deny",
2030
+ key: event.key,
2031
+ scope: event.scope,
2032
+ source: "hook",
2033
+ detail: `canary alert SSRF blocked: ${describeAlertUrl(channel.url)}`
2034
+ });
2035
+ return { ...base, success: false, message: ssrfBlock };
2036
+ }
2037
+ try {
2038
+ const { body, headers } = buildAlertPayload(channel.type, event);
2039
+ const res = await httpRequest({
2040
+ url: channel.url,
2041
+ method: "POST",
2042
+ headers,
2043
+ body,
2044
+ timeoutMs: 1e4
2045
+ });
2046
+ return {
2047
+ ...base,
2048
+ success: res.statusCode >= 200 && res.statusCode < 300,
2049
+ message: `HTTP ${res.statusCode}`
2050
+ };
2051
+ } catch (err) {
2052
+ return { ...base, success: false, message: err instanceof Error ? err.message : "HTTP error" };
2053
+ }
2054
+ }
2055
+ async function sendCanaryAlerts(event, onlyId) {
2056
+ const channels = listCanaryAlerts().filter((c) => onlyId ? c.id === onlyId : c.enabled);
2057
+ if (channels.length === 0) return [];
2058
+ const results = await Promise.allSettled(channels.map((c) => sendOne(c, event)));
2059
+ return results.map(
2060
+ (r, i) => r.status === "fulfilled" ? r.value : {
2061
+ channelId: channels[i].id,
2062
+ type: channels[i].type,
2063
+ success: false,
2064
+ message: String(r.reason)
2065
+ }
2066
+ );
2067
+ }
2068
+
1832
2069
  // src/core/canary-alert.ts
1833
2070
  var TRIP_THROTTLE_MS = 30 * 1e3;
1834
2071
  var lastAlerted = /* @__PURE__ */ new Map();
@@ -1842,16 +2079,27 @@ function recordCanaryTrip(trip) {
1842
2079
  source: trip.source,
1843
2080
  detail: `CANARY TRIPPED: ${trip.detail ?? `honeytoken read via ${trip.source}`}`
1844
2081
  });
1845
- if (!notificationsEnabled()) return;
1846
2082
  const now = Date.now();
1847
2083
  const last = lastAlerted.get(trip.key);
1848
2084
  if (last !== void 0 && now - last < TRIP_THROTTLE_MS) return;
1849
2085
  lastAlerted.set(trip.key, now);
1850
- const who = agent ? `${trip.source} (${agent})` : trip.source;
1851
- notifyUser(
1852
- "q-ring: CANARY TRIPPED",
1853
- `Honeytoken "${trip.key}" was read by ${who}. This credential is fake \u2014 but something reached for it. Investigate: qring audit --action canary`
1854
- );
2086
+ if (notificationsEnabled()) {
2087
+ const who = agent ? `${trip.source} (${agent})` : trip.source;
2088
+ notifyUser(
2089
+ "q-ring: CANARY TRIPPED",
2090
+ `Honeytoken "${trip.key}" was read by ${who}. This credential is fake \u2014 but something reached for it. Investigate: qring audit --action canary`
2091
+ );
2092
+ }
2093
+ void sendCanaryAlerts({
2094
+ key: trip.key,
2095
+ scope: trip.scope,
2096
+ env: trip.env,
2097
+ source: trip.source,
2098
+ agent,
2099
+ detail: trip.detail ?? `honeytoken read via ${trip.source}`,
2100
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2101
+ }).catch(() => {
2102
+ });
1855
2103
  }
1856
2104
 
1857
2105
  // src/core/provision.ts
@@ -2567,9 +2815,9 @@ function tunnelList() {
2567
2815
  }
2568
2816
 
2569
2817
  // src/core/memory.ts
2570
- import { existsSync as existsSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync6, mkdirSync as mkdirSync7, chmodSync as chmodSync3 } from "fs";
2571
- import { join as join10 } from "path";
2572
- import { homedir as homedir7, hostname, userInfo } from "os";
2818
+ import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync7, mkdirSync as mkdirSync8, chmodSync as chmodSync3 } from "fs";
2819
+ import { join as join11 } from "path";
2820
+ import { homedir as homedir8, hostname, userInfo } from "os";
2573
2821
  import {
2574
2822
  createCipheriv as createCipheriv2,
2575
2823
  createDecipheriv as createDecipheriv2,
@@ -2581,21 +2829,21 @@ var MEMORY_FILE = "agent-memory.enc";
2581
2829
  var KEYRING_SERVICE = "qring-memory-key";
2582
2830
  var KEYRING_ACCOUNT = "encryption-key";
2583
2831
  function getMemoryDir() {
2584
- const dir = join10(homedir7(), ".config", "q-ring");
2585
- if (!existsSync7(dir)) {
2586
- mkdirSync7(dir, { recursive: true, mode: 448 });
2832
+ const dir = join11(homedir8(), ".config", "q-ring");
2833
+ if (!existsSync8(dir)) {
2834
+ mkdirSync8(dir, { recursive: true, mode: 448 });
2587
2835
  }
2588
2836
  return dir;
2589
2837
  }
2590
2838
  function writeMemoryFile(path, data) {
2591
- writeFileSync6(path, data, { mode: 384 });
2839
+ writeFileSync7(path, data, { mode: 384 });
2592
2840
  try {
2593
2841
  chmodSync3(path, 384);
2594
2842
  } catch {
2595
2843
  }
2596
2844
  }
2597
2845
  function getMemoryPath() {
2598
- return join10(getMemoryDir(), MEMORY_FILE);
2846
+ return join11(getMemoryDir(), MEMORY_FILE);
2599
2847
  }
2600
2848
  var PBKDF2_ITERATIONS2 = 21e4;
2601
2849
  var KEY_LENGTH2 = 32;
@@ -2689,7 +2937,7 @@ function decrypt(blob) {
2689
2937
  }
2690
2938
  function loadStore2() {
2691
2939
  const path = getMemoryPath();
2692
- if (!existsSync7(path)) {
2940
+ if (!existsSync8(path)) {
2693
2941
  return { entries: {} };
2694
2942
  }
2695
2943
  try {
@@ -2740,8 +2988,80 @@ function clearMemory() {
2740
2988
  saveStore2({ entries: {} });
2741
2989
  }
2742
2990
 
2991
+ // src/core/sessions.ts
2992
+ var DEFAULT_MAX_EVENTS = 200;
2993
+ var UNLABELED = "unlabeled";
2994
+ function slug(label) {
2995
+ return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || UNLABELED;
2996
+ }
2997
+ function sessionKeyFor(event) {
2998
+ if (event.action === "wrap" && event.correlationId) return `wrap:${event.correlationId}`;
2999
+ if (event.agent) return `pid:${event.pid}:${slug(event.agent)}`;
3000
+ if (event.source === "mcp" || event.source === "agent") return `pid:${event.pid}:${UNLABELED}`;
3001
+ return null;
3002
+ }
3003
+ function wrapLabelFrom(detail) {
3004
+ const m = detail?.match(/^airlock session started: (.+?)(?: \(env (?:inherited|stripped)\))?$/);
3005
+ return m?.[1];
3006
+ }
3007
+ function buildSessions(events, maxEvents = DEFAULT_MAX_EVENTS) {
3008
+ const byKey = /* @__PURE__ */ new Map();
3009
+ for (const e of events) {
3010
+ const key = sessionKeyFor(e);
3011
+ if (!key) continue;
3012
+ const bucket = byKey.get(key);
3013
+ if (bucket) bucket.push(e);
3014
+ else byKey.set(key, [e]);
3015
+ }
3016
+ const sessions = [];
3017
+ for (const [key, bucket] of byKey) {
3018
+ bucket.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
3019
+ const first = bucket[0];
3020
+ const last = bucket[bucket.length - 1];
3021
+ const countsByAction = {};
3022
+ const sources = /* @__PURE__ */ new Map();
3023
+ const keys = /* @__PURE__ */ new Set();
3024
+ let denials = 0;
3025
+ let wrapLabel;
3026
+ for (const e of bucket) {
3027
+ countsByAction[e.action] = (countsByAction[e.action] ?? 0) + 1;
3028
+ sources.set(e.source, (sources.get(e.source) ?? 0) + 1);
3029
+ if (e.key) keys.add(e.key);
3030
+ if (e.action === "policy_deny") denials++;
3031
+ if (!wrapLabel && e.action === "wrap") wrapLabel = wrapLabelFrom(e.detail);
3032
+ }
3033
+ const source = [...sources.entries()].sort((a, b) => b[1] - a[1])[0][0];
3034
+ const agent = bucket.find((e) => e.agent)?.agent ?? UNLABELED;
3035
+ const isWrap = key.startsWith("wrap:");
3036
+ sessions.push({
3037
+ id: isWrap ? key.slice("wrap:".length) : `${first.pid}-${slug(agent)}`,
3038
+ agent,
3039
+ source,
3040
+ pid: first.pid,
3041
+ startedAt: first.timestamp,
3042
+ endedAt: last.timestamp,
3043
+ eventCount: bucket.length,
3044
+ countsByAction,
3045
+ keys: [...keys].sort(),
3046
+ denials,
3047
+ ...isWrap ? { wrapLabel: wrapLabel ?? "(unknown command)" } : {},
3048
+ events: bucket.slice(-maxEvents).reverse()
3049
+ });
3050
+ }
3051
+ sessions.sort((a, b) => new Date(b.endedAt).getTime() - new Date(a.endedAt).getTime());
3052
+ return sessions;
3053
+ }
3054
+ function loadEvents(query) {
3055
+ return queryAudit({ since: query.since, agent: query.agent }).filter((e) => e.action !== "list");
3056
+ }
3057
+ function listAgentSessions(query = {}) {
3058
+ const sessions = buildSessions(loadEvents(query), query.maxEvents);
3059
+ return query.limit ? sessions.slice(0, query.limit) : sessions;
3060
+ }
3061
+
2743
3062
  export {
2744
3063
  PACKAGE_VERSION,
3064
+ hashProjectPath,
2745
3065
  serviceForScope,
2746
3066
  checkDecay,
2747
3067
  readProjectConfig,
@@ -2763,12 +3083,24 @@ export {
2763
3083
  fireHooks,
2764
3084
  grantApproval,
2765
3085
  revokeApproval,
3086
+ hasApproval,
2766
3087
  countLegacyApprovals,
2767
3088
  listApprovals,
3089
+ addCanaryAlert,
3090
+ listCanaryAlerts,
3091
+ removeCanaryAlert,
3092
+ setCanaryAlertEnabled,
3093
+ describeAlertUrl,
3094
+ sendCanaryAlerts,
2768
3095
  registry,
3096
+ setPolicyRoot,
2769
3097
  checkExecPolicy,
2770
3098
  getExecMaxRuntime,
2771
3099
  getPolicySummary,
3100
+ checkWrapToolPolicy,
3101
+ wrapToolRequiresApproval,
3102
+ getWrapRateLimit,
3103
+ wrapRedactsResults,
2772
3104
  getSecret,
2773
3105
  getEnvelope,
2774
3106
  setSecret,
@@ -2787,6 +3119,8 @@ export {
2787
3119
  recall,
2788
3120
  listMemory,
2789
3121
  forget,
2790
- clearMemory
3122
+ clearMemory,
3123
+ buildSessions,
3124
+ listAgentSessions
2791
3125
  };
2792
- //# sourceMappingURL=chunk-SQKTDYSO.js.map
3126
+ //# sourceMappingURL=chunk-5ZCQSBVS.js.map