@i4ctime/q-ring 0.17.0 → 0.17.5
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 +89 -7
- package/dist/{chunk-SQKTDYSO.js → chunk-5ZCQSBVS.js} +352 -18
- package/dist/chunk-5ZCQSBVS.js.map +1 -0
- package/dist/{chunk-KFILBHOY.js → chunk-VMDD5QTJ.js} +247 -18
- package/dist/chunk-VMDD5QTJ.js.map +1 -0
- package/dist/{dashboard-2MLSJZQR.js → dashboard-K7LYTWSL.js} +33 -6
- package/dist/dashboard-K7LYTWSL.js.map +1 -0
- package/dist/{dashboard-WVPR5BQO.js → dashboard-ZN3FPR73.js} +33 -6
- package/dist/dashboard-ZN3FPR73.js.map +1 -0
- package/dist/index.js +659 -115
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +68 -4
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-KFILBHOY.js.map +0 -1
- package/dist/chunk-SQKTDYSO.js.map +0 -1
- package/dist/dashboard-2MLSJZQR.js.map +0 -1
- package/dist/dashboard-WVPR5BQO.js.map +0 -1
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,
|
|
512
|
-
|
|
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`),
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
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
|
|
2571
|
-
import { join as
|
|
2572
|
-
import { homedir as
|
|
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 =
|
|
2585
|
-
if (!
|
|
2586
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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-
|
|
3126
|
+
//# sourceMappingURL=chunk-5ZCQSBVS.js.map
|