agentcollar 0.0.1 → 0.2.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/LICENSE +27 -0
- package/README.md +83 -4
- package/dist/approval.js +67 -0
- package/dist/audit-log.js +37 -0
- package/dist/audit.js +23 -0
- package/dist/check.js +52 -0
- package/dist/cli/format.js +86 -0
- package/dist/cli/gmail-guide.js +87 -0
- package/dist/cli/gmail.js +125 -0
- package/dist/cli/intro-frames.js +40 -0
- package/dist/cli/intro.js +121 -0
- package/dist/cli/logs.js +46 -0
- package/dist/cli/main.js +104 -0
- package/dist/cli/mandates.js +101 -0
- package/dist/cli/revoke.js +8 -0
- package/dist/cli/setup.js +124 -0
- package/dist/cli/start.js +29 -0
- package/dist/cli/tail.js +46 -0
- package/dist/cli/watch.js +44 -0
- package/dist/cli.js +4 -0
- package/dist/env.js +13 -0
- package/dist/gmail-fake.js +26 -0
- package/dist/google/connection.js +36 -0
- package/dist/google/gmail.js +73 -0
- package/dist/google/keychain.js +40 -0
- package/dist/google/oauth.js +134 -0
- package/dist/mailbox.js +4 -0
- package/dist/mandate.js +82 -0
- package/dist/mcp/protocol.js +73 -0
- package/dist/mcp/server.js +22 -0
- package/dist/mcp/tools.js +147 -0
- package/dist/paths.js +44 -0
- package/dist/revoke.js +18 -0
- package/dist/server.js +269 -0
- package/dist/setup.js +100 -0
- package/dist/snapshot.js +42 -0
- package/dist/telegram.js +124 -0
- package/package.json +50 -5
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// agentcollar watch — the audit log live: every agent request as it happens, with the 6 checks.
|
|
2
|
+
// Read-only: it only watches data/audit.log, no HTTP and no way to change anything.
|
|
3
|
+
import { mkdirSync, watch } from "node:fs";
|
|
4
|
+
import { basename, dirname } from "node:path";
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { auditLogFile } from "../audit.js";
|
|
7
|
+
import { fit, formatEntry, isToday, termWidth } from "./format.js";
|
|
8
|
+
import { createTail } from "./tail.js";
|
|
9
|
+
const RECENT = 10; // how many past events to show first, for context
|
|
10
|
+
function print(entry) {
|
|
11
|
+
console.log(formatEntry(entry, { showDate: !isToday(entry.time) }));
|
|
12
|
+
}
|
|
13
|
+
export async function runWatch(args) {
|
|
14
|
+
if (args.length > 0) {
|
|
15
|
+
console.error(`agcl watch takes no arguments: ${args.join(" ")}`);
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
console.log(`${styleText("bold", "AgentCollar · watch")} ${styleText("dim", "Ctrl+C to quit")}`);
|
|
19
|
+
const legend = termWidth() >= 90
|
|
20
|
+
? "checks: 1 token · 2 approved · 3 not expired · 4 not revoked · 5 action allowed · 6 limit"
|
|
21
|
+
: "✓✗· = checks: token, approved, time, revoked, action, limit";
|
|
22
|
+
console.log(styleText("dim", fit(legend, termWidth())));
|
|
23
|
+
const tail = createTail(auditLogFile);
|
|
24
|
+
const past = tail.readNew().slice(-RECENT);
|
|
25
|
+
if (past.length > 0) {
|
|
26
|
+
console.log(styleText("dim", "— recent —"));
|
|
27
|
+
past.forEach(print);
|
|
28
|
+
}
|
|
29
|
+
console.log(styleText("dim", "— live —"));
|
|
30
|
+
// Watch the FOLDER, not the file: it also works before the log exists, and after it is replaced.
|
|
31
|
+
const folder = dirname(auditLogFile);
|
|
32
|
+
mkdirSync(folder, { recursive: true });
|
|
33
|
+
const watcher = watch(folder, (_event, name) => {
|
|
34
|
+
if (name === null || name === basename(auditLogFile))
|
|
35
|
+
tail.readNew().forEach(print);
|
|
36
|
+
});
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
process.once("SIGINT", () => {
|
|
39
|
+
watcher.close();
|
|
40
|
+
process.stdout.write("\n");
|
|
41
|
+
resolve(0);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
package/dist/cli.js
ADDED
package/dist/env.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Loads ~/.agentcollar/.env into process.env (secrets live there, never in the code).
|
|
2
|
+
// Imported first by the server and by the CLI commands.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { envFile, legacyFiles } from "./paths.js";
|
|
5
|
+
if (existsSync(envFile)) {
|
|
6
|
+
process.loadEnvFile(envFile); // built into Node: puts the lines of .env into process.env
|
|
7
|
+
}
|
|
8
|
+
else if (process.env.AGENTCOLLAR_HOME === undefined && existsSync(legacyFiles.env)) {
|
|
9
|
+
// Older setups kept it in broker/.env. Still works; `agcl setup` offers to move it.
|
|
10
|
+
// (Not when AGENTCOLLAR_HOME is set: an explicit home means "look only there".)
|
|
11
|
+
process.loadEnvFile(legacyFiles.env);
|
|
12
|
+
console.error("AgentCollar: settings are still in broker/.env, run agcl setup to move them to ~/.agentcollar/");
|
|
13
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// A pretend mailbox in memory: the broker uses it until you run `agcl gmail connect`.
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
const newId = () => randomBytes(4).toString("hex");
|
|
4
|
+
export function createFakeMailbox() {
|
|
5
|
+
const inbox = [
|
|
6
|
+
{ id: "m1", from: "anna@example.com", subject: "Lunch on Friday?", body: "Are you free at 13:00?" },
|
|
7
|
+
{ id: "m2", from: "bank@example.com", subject: "Your statement", body: "Your October statement is ready." },
|
|
8
|
+
{ id: "m3", from: "boss@example.com", subject: "Report", body: "Please send the report by Monday." },
|
|
9
|
+
];
|
|
10
|
+
const drafts = [];
|
|
11
|
+
const sent = [];
|
|
12
|
+
return {
|
|
13
|
+
kind: "fake",
|
|
14
|
+
listInbox: async () => inbox,
|
|
15
|
+
async createDraft(to, subject, body) {
|
|
16
|
+
const draft = { id: newId(), to, subject, body };
|
|
17
|
+
drafts.push(draft);
|
|
18
|
+
return draft;
|
|
19
|
+
},
|
|
20
|
+
async sendEmail(to, subject, body) {
|
|
21
|
+
const email = { id: newId(), to, subject, body };
|
|
22
|
+
sent.push(email);
|
|
23
|
+
return email;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Which mailbox the broker uses: your real Gmail if `agcl gmail connect` was done, otherwise the fake one.
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createFakeMailbox } from "../gmail-fake.js";
|
|
4
|
+
import { ensureHome, gmailInfoFile, googleClientFile } from "../paths.js";
|
|
5
|
+
import { createGmailMailbox } from "./gmail.js";
|
|
6
|
+
import { keychain } from "./keychain.js";
|
|
7
|
+
import { parseClientJson } from "./oauth.js";
|
|
8
|
+
export const REFRESH_TOKEN_ACCOUNT = "gmail-refresh-token";
|
|
9
|
+
export function readGmailInfo() {
|
|
10
|
+
if (!existsSync(gmailInfoFile))
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(gmailInfoFile, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function saveConnection(info, refreshToken, store = keychain) {
|
|
20
|
+
ensureHome();
|
|
21
|
+
store.set(REFRESH_TOKEN_ACCOUNT, refreshToken); // the secret goes to the Keychain…
|
|
22
|
+
writeFileSync(gmailInfoFile, JSON.stringify(info, null, 2), { mode: 0o600 }); // …the file has only the address
|
|
23
|
+
}
|
|
24
|
+
export function forgetConnection(store = keychain) {
|
|
25
|
+
const token = store.get(REFRESH_TOKEN_ACCOUNT);
|
|
26
|
+
store.remove(REFRESH_TOKEN_ACCOUNT);
|
|
27
|
+
if (existsSync(gmailInfoFile))
|
|
28
|
+
unlinkSync(gmailInfoFile);
|
|
29
|
+
return token;
|
|
30
|
+
}
|
|
31
|
+
export function loadMailbox(store = keychain) {
|
|
32
|
+
const refreshToken = store.get(REFRESH_TOKEN_ACCOUNT);
|
|
33
|
+
if (refreshToken === null || !existsSync(googleClientFile))
|
|
34
|
+
return createFakeMailbox();
|
|
35
|
+
return createGmailMailbox(parseClientJson(readFileSync(googleClientFile, "utf8")), refreshToken);
|
|
36
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Your real Gmail, through the Gmail API. Same shape as the fake mailbox, so the broker does not care
|
|
2
|
+
// which one it talks to. The broker calls these ONLY after check() said yes.
|
|
3
|
+
import { isEmailAddress } from "../mailbox.js";
|
|
4
|
+
import { refreshAccessToken } from "./oauth.js";
|
|
5
|
+
const API = "https://gmail.googleapis.com/gmail/v1/users/me";
|
|
6
|
+
// Builds the email in the format Gmail expects ("raw" = base64url of the whole message).
|
|
7
|
+
// The subject comes from an agent, so line breaks are removed: otherwise "Hi\r\nBcc: thief@evil"
|
|
8
|
+
// would add a hidden recipient (header injection).
|
|
9
|
+
export function buildRawEmail(to, subject, body) {
|
|
10
|
+
if (!isEmailAddress(to))
|
|
11
|
+
throw new Error(`"to" must be one email address, got: ${JSON.stringify(to)}`);
|
|
12
|
+
const cleanSubject = subject.replace(/[\r\n]+/g, " ").trim();
|
|
13
|
+
// non-ASCII subjects are encoded (RFC 2047) so every mail client shows them correctly
|
|
14
|
+
const encodedSubject = /^[\x20-\x7e]*$/.test(cleanSubject)
|
|
15
|
+
? cleanSubject
|
|
16
|
+
: `=?UTF-8?B?${Buffer.from(cleanSubject, "utf8").toString("base64")}?=`;
|
|
17
|
+
const message = [
|
|
18
|
+
`To: ${to}`,
|
|
19
|
+
`Subject: ${encodedSubject}`,
|
|
20
|
+
"MIME-Version: 1.0",
|
|
21
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
22
|
+
"Content-Transfer-Encoding: base64",
|
|
23
|
+
"",
|
|
24
|
+
Buffer.from(body, "utf8").toString("base64"),
|
|
25
|
+
].join("\r\n");
|
|
26
|
+
return Buffer.from(message, "utf8").toString("base64url");
|
|
27
|
+
}
|
|
28
|
+
export function createGmailMailbox(client, refreshToken) {
|
|
29
|
+
// The access token lives only in this process's memory, never on disk.
|
|
30
|
+
let accessToken = "";
|
|
31
|
+
let expiresAt = 0;
|
|
32
|
+
async function token() {
|
|
33
|
+
if (Date.now() > expiresAt - 60_000) {
|
|
34
|
+
const fresh = await refreshAccessToken(client, refreshToken);
|
|
35
|
+
accessToken = fresh.accessToken;
|
|
36
|
+
expiresAt = fresh.expiresAt;
|
|
37
|
+
}
|
|
38
|
+
return accessToken;
|
|
39
|
+
}
|
|
40
|
+
async function api(method, path, body) {
|
|
41
|
+
const response = await fetch(API + path, {
|
|
42
|
+
method,
|
|
43
|
+
headers: { authorization: `Bearer ${await token()}`, "content-type": "application/json" },
|
|
44
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
45
|
+
});
|
|
46
|
+
const data = (await response.json());
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new Error(`Gmail API: ${data.error?.message ?? response.status}`);
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
kind: "gmail",
|
|
53
|
+
async listInbox() {
|
|
54
|
+
const list = await api("GET", "/messages?labelIds=INBOX&maxResults=10");
|
|
55
|
+
const ids = (list.messages ?? []).map((m) => m.id);
|
|
56
|
+
return Promise.all(ids.map(async (id) => {
|
|
57
|
+
const m = await api("GET", `/messages/${id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject`);
|
|
58
|
+
const headers = m.payload?.headers ?? [];
|
|
59
|
+
const header = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
|
|
60
|
+
return { id, from: header("From"), subject: header("Subject"), body: String(m.snippet ?? "") };
|
|
61
|
+
}));
|
|
62
|
+
},
|
|
63
|
+
async createDraft(to, subject, body) {
|
|
64
|
+
const draft = await api("POST", "/drafts", { message: { raw: buildRawEmail(to, subject, body) } });
|
|
65
|
+
return { id: String(draft.id), to, subject, body };
|
|
66
|
+
},
|
|
67
|
+
// AgentCollar asks Google only for read + create drafts, so this connection cannot send.
|
|
68
|
+
// Even a mandate with gmail.send ends here: the email stays a draft for you to send yourself.
|
|
69
|
+
async sendEmail() {
|
|
70
|
+
throw new Error("This Gmail connection cannot send: AgentCollar only has read + create-drafts access. Create a draft instead; the human sends it.");
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Secrets in the macOS Keychain (encrypted by macOS, outside the repo and outside ~/.agentcollar/),
|
|
2
|
+
// through the built-in `security` tool. Used for the Gmail refresh token.
|
|
3
|
+
// Honest limit: other programs running as YOUR macOS user can also ask `security` for it.
|
|
4
|
+
// The Keychain protects against leaks through files, backups and git, not against malware you run.
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
const SERVICE = "agentcollar";
|
|
7
|
+
export const keychain = {
|
|
8
|
+
get(account) {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"], {
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
+
}).trimEnd();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null; // not there (or not macOS)
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
set(account, value) {
|
|
20
|
+
// -U: update if it already exists
|
|
21
|
+
execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", value], { stdio: "ignore" });
|
|
22
|
+
},
|
|
23
|
+
remove(account) {
|
|
24
|
+
try {
|
|
25
|
+
execFileSync("security", ["delete-generic-password", "-s", SERVICE, "-a", account], { stdio: "ignore" });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// already gone
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
// For tests: the same interface, kept in memory.
|
|
33
|
+
export function memoryStore() {
|
|
34
|
+
const values = new Map();
|
|
35
|
+
return {
|
|
36
|
+
get: (account) => values.get(account) ?? null,
|
|
37
|
+
set: (account, value) => void values.set(account, value),
|
|
38
|
+
remove: (account) => void values.delete(account),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Google sign-in for a desktop program (OAuth 2.0 "installed app" flow with PKCE):
|
|
2
|
+
// we open Google's consent page in your browser, Google sends the browser back to a tiny
|
|
3
|
+
// server on 127.0.0.1 with a one-time code, and we swap that code for tokens.
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
// Read + create drafts, nothing more. gmail.drafts.create cannot send: so sending is blocked twice,
|
|
7
|
+
// by the broker (no mandate, no send) AND by Google (this connection has no right to send at all).
|
|
8
|
+
export const SCOPES = ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.drafts.create"];
|
|
9
|
+
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
10
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
11
|
+
// The JSON file you download from Google Cloud → Clients → Desktop app.
|
|
12
|
+
export function parseClientJson(text) {
|
|
13
|
+
let data;
|
|
14
|
+
try {
|
|
15
|
+
data = JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error("This is not a Google client_secret JSON file.");
|
|
19
|
+
}
|
|
20
|
+
if (data.web !== undefined)
|
|
21
|
+
throw new Error("This client is a Web app. Create a client of type Desktop app instead.");
|
|
22
|
+
const installed = data.installed;
|
|
23
|
+
if (typeof installed?.client_id !== "string" || typeof installed.client_secret !== "string") {
|
|
24
|
+
throw new Error("This is not a Google client_secret JSON file (no installed.client_id).");
|
|
25
|
+
}
|
|
26
|
+
return { clientId: installed.client_id, clientSecret: installed.client_secret };
|
|
27
|
+
}
|
|
28
|
+
// PKCE: we keep a random secret (verifier) and send Google only its hash (challenge).
|
|
29
|
+
// A stolen code is useless without the verifier, which never leaves this process.
|
|
30
|
+
export function newPkce() {
|
|
31
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
32
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
33
|
+
}
|
|
34
|
+
export function buildAuthUrl(client, redirectUri, challenge, state) {
|
|
35
|
+
const url = new URL(AUTH_URL);
|
|
36
|
+
url.search = new URLSearchParams({
|
|
37
|
+
client_id: client.clientId,
|
|
38
|
+
redirect_uri: redirectUri,
|
|
39
|
+
response_type: "code",
|
|
40
|
+
scope: SCOPES.join(" "),
|
|
41
|
+
code_challenge: challenge,
|
|
42
|
+
code_challenge_method: "S256",
|
|
43
|
+
state, // a random value: proves the answer belongs to OUR request
|
|
44
|
+
access_type: "offline", // we want a refresh token
|
|
45
|
+
prompt: "consent", // always show what is being granted (and always return a refresh token)
|
|
46
|
+
}).toString();
|
|
47
|
+
return url.toString();
|
|
48
|
+
}
|
|
49
|
+
async function postToken(params) {
|
|
50
|
+
const response = await fetch(TOKEN_URL, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
53
|
+
body: new URLSearchParams(params).toString(),
|
|
54
|
+
});
|
|
55
|
+
const data = (await response.json());
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
if (data.error === "invalid_grant") {
|
|
58
|
+
throw new Error("Google no longer accepts this sign-in (in Testing mode it expires after 7 days). Run: agcl gmail connect");
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Google sign-in failed: ${String(data.error_description ?? data.error ?? response.status)}`);
|
|
61
|
+
}
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
export async function exchangeCode(client, code, verifier, redirectUri) {
|
|
65
|
+
const data = await postToken({
|
|
66
|
+
client_id: client.clientId,
|
|
67
|
+
client_secret: client.clientSecret,
|
|
68
|
+
code,
|
|
69
|
+
code_verifier: verifier,
|
|
70
|
+
redirect_uri: redirectUri,
|
|
71
|
+
grant_type: "authorization_code",
|
|
72
|
+
});
|
|
73
|
+
if (typeof data.refresh_token !== "string")
|
|
74
|
+
throw new Error("Google did not return a refresh token. Run agcl gmail connect again.");
|
|
75
|
+
return {
|
|
76
|
+
accessToken: String(data.access_token),
|
|
77
|
+
refreshToken: data.refresh_token,
|
|
78
|
+
expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// The access token lives about an hour and only in memory; the refresh token gets a new one.
|
|
82
|
+
export async function refreshAccessToken(client, refreshToken) {
|
|
83
|
+
const data = await postToken({
|
|
84
|
+
client_id: client.clientId,
|
|
85
|
+
client_secret: client.clientSecret,
|
|
86
|
+
refresh_token: refreshToken,
|
|
87
|
+
grant_type: "refresh_token",
|
|
88
|
+
});
|
|
89
|
+
return { accessToken: String(data.access_token), expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000 };
|
|
90
|
+
}
|
|
91
|
+
export async function revokeToken(token) {
|
|
92
|
+
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, { method: "POST" }).catch(() => { });
|
|
93
|
+
}
|
|
94
|
+
// Starts the loopback server, opens the consent page, waits (up to 5 minutes) for Google's redirect.
|
|
95
|
+
export function authorizeInBrowser(client, openUrl, timeoutMs = 5 * 60 * 1000) {
|
|
96
|
+
const { verifier, challenge } = newPkce();
|
|
97
|
+
const state = randomBytes(16).toString("hex");
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const server = createServer((req, res) => {
|
|
100
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
101
|
+
if (url.pathname !== "/callback") {
|
|
102
|
+
res.writeHead(404).end();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (url.searchParams.get("state") !== state) {
|
|
106
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("This sign-in answer does not belong to AgentCollar's request.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const code = url.searchParams.get("code");
|
|
110
|
+
if (code === null) {
|
|
111
|
+
res.writeHead(400, { "content-type": "text/plain" }).end(`Google sign-in was cancelled: ${url.searchParams.get("error") ?? "no code"}`);
|
|
112
|
+
finish(() => reject(new Error("Google sign-in was cancelled.")));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end("<!doctype html><title>AgentCollar</title><body style=\"font-family:system-ui;padding:3rem\">" +
|
|
116
|
+
"<h1>AgentCollar is connected to Gmail.</h1><p>You can close this tab and go back to the terminal.</p>");
|
|
117
|
+
finish(() => resolve({ code, verifier, redirectUri }));
|
|
118
|
+
});
|
|
119
|
+
let redirectUri = "";
|
|
120
|
+
const timer = setTimeout(() => finish(() => reject(new Error("No answer from Google in 5 minutes."))), timeoutMs);
|
|
121
|
+
function finish(then) {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
server.close();
|
|
124
|
+
then();
|
|
125
|
+
}
|
|
126
|
+
// port 0 = any free port; 127.0.0.1 = reachable only from this computer
|
|
127
|
+
server.listen(0, "127.0.0.1", () => {
|
|
128
|
+
const address = server.address();
|
|
129
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
130
|
+
redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
131
|
+
openUrl(buildAuthUrl(client, redirectUri, challenge, state));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
package/dist/mailbox.js
ADDED
package/dist/mandate.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
// In-memory store: token -> mandate.
|
|
3
|
+
// It disappears when the program stops. That is fine for now.
|
|
4
|
+
export const mandates = new Map();
|
|
5
|
+
// Called after every change. The server uses it to write data/mandates.json for `agentcollar mandates`.
|
|
6
|
+
let changeListener = () => { };
|
|
7
|
+
export function onMandatesChanged(listener) {
|
|
8
|
+
changeListener = listener;
|
|
9
|
+
}
|
|
10
|
+
export function mandatesChanged() {
|
|
11
|
+
changeListener();
|
|
12
|
+
}
|
|
13
|
+
// An agent asks for a mandate. It stays "pending" until a human decides.
|
|
14
|
+
export function requestMandate(agent, task, allowedActions, expiresInSeconds, limit) {
|
|
15
|
+
const mandate = {
|
|
16
|
+
id: randomBytes(4).toString("hex"), // 8 characters, easy to read; not a secret
|
|
17
|
+
// 32 random bytes = 256 bits: impossible to guess. As hex text it is 64 characters.
|
|
18
|
+
token: randomBytes(32).toString("hex"),
|
|
19
|
+
agent,
|
|
20
|
+
task,
|
|
21
|
+
allowedActions,
|
|
22
|
+
status: "pending",
|
|
23
|
+
expiresInSeconds,
|
|
24
|
+
expiresAt: 0,
|
|
25
|
+
limit,
|
|
26
|
+
used: 0,
|
|
27
|
+
revoked: false,
|
|
28
|
+
createdAt: Date.now(),
|
|
29
|
+
};
|
|
30
|
+
mandates.set(mandate.token, mandate);
|
|
31
|
+
mandatesChanged();
|
|
32
|
+
return mandate;
|
|
33
|
+
}
|
|
34
|
+
// Shortcut used by the Phase 1 demo: request + approve at once, returns the token.
|
|
35
|
+
export function issueMandate(agent, task, allowedActions, expiresInSeconds, limit) {
|
|
36
|
+
const mandate = requestMandate(agent, task, allowedActions, expiresInSeconds, limit);
|
|
37
|
+
approve(mandate.id);
|
|
38
|
+
return mandate.token;
|
|
39
|
+
}
|
|
40
|
+
// The human side only knows the public id, never the token.
|
|
41
|
+
export function findById(id) {
|
|
42
|
+
for (const mandate of mandates.values()) {
|
|
43
|
+
if (mandate.id === id)
|
|
44
|
+
return mandate;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
// Pending mandates nobody answered for longer than maxAgeMs (the server uses 10 minutes).
|
|
49
|
+
export function findStalePending(maxAgeMs, now = Date.now()) {
|
|
50
|
+
return [...mandates.values()].filter((mandate) => mandate.status === "pending" && now - mandate.createdAt > maxAgeMs);
|
|
51
|
+
}
|
|
52
|
+
// Human says yes. Only a pending mandate can be decided, and only once.
|
|
53
|
+
export function approve(id) {
|
|
54
|
+
const mandate = findById(id);
|
|
55
|
+
if (mandate === undefined || mandate.status !== "pending")
|
|
56
|
+
return undefined;
|
|
57
|
+
mandate.status = "approved";
|
|
58
|
+
mandate.expiresAt = Date.now() + mandate.expiresInSeconds * 1000;
|
|
59
|
+
mandatesChanged();
|
|
60
|
+
return mandate;
|
|
61
|
+
}
|
|
62
|
+
// Human says no.
|
|
63
|
+
export function deny(id) {
|
|
64
|
+
const mandate = findById(id);
|
|
65
|
+
if (mandate === undefined || mandate.status !== "pending")
|
|
66
|
+
return undefined;
|
|
67
|
+
mandate.status = "denied";
|
|
68
|
+
mandatesChanged();
|
|
69
|
+
return mandate;
|
|
70
|
+
}
|
|
71
|
+
// Kill switch: the human stops a mandate immediately.
|
|
72
|
+
// We mark it instead of deleting it, so check() can still say "revoked" (not "unknown")
|
|
73
|
+
// and the audit log still knows which agent it belonged to.
|
|
74
|
+
// Phase 1 passed the token; now the human side uses the public id.
|
|
75
|
+
export function revoke(id) {
|
|
76
|
+
const mandate = findById(id);
|
|
77
|
+
if (mandate === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
mandate.revoked = true;
|
|
80
|
+
mandatesChanged();
|
|
81
|
+
return mandate;
|
|
82
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The small part of MCP (Model Context Protocol) we need: tools over JSON-RPC 2.0.
|
|
2
|
+
// One message = one line of JSON. A request has an "id" and gets a reply with the same id;
|
|
3
|
+
// a notification has no "id" and gets no reply. This file knows nothing about the broker.
|
|
4
|
+
// Newest first. If the client asks for one of these, we answer with it; otherwise with the newest.
|
|
5
|
+
export const SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
6
|
+
const SERVER_INFO = { name: "agentcollar", version: "0.1.0" };
|
|
7
|
+
const INSTRUCTIONS = "AgentCollar guards the human's accounts. First call request_mandate with only the actions you need; " +
|
|
8
|
+
"the human approves it in Telegram. Then call mandate_status once with waitSeconds: 90 (it returns as soon " +
|
|
9
|
+
"as the human decides) and, if approved, pass the mandateId " +
|
|
10
|
+
"to the gmail_* tools. Refusals are final: explain them to the human instead of retrying.";
|
|
11
|
+
function reply(id, result) {
|
|
12
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
13
|
+
}
|
|
14
|
+
function error(id, code, message) {
|
|
15
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
16
|
+
}
|
|
17
|
+
// Handles one incoming line. Returns the reply line, or null when no reply is due.
|
|
18
|
+
export async function handleMessage(line, context) {
|
|
19
|
+
if (line.trim() === "")
|
|
20
|
+
return null;
|
|
21
|
+
let message;
|
|
22
|
+
try {
|
|
23
|
+
message = JSON.parse(line);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return error(null, -32700, "Parse error");
|
|
27
|
+
}
|
|
28
|
+
if (typeof message !== "object" || message === null || Array.isArray(message)) {
|
|
29
|
+
return error(null, -32600, "Invalid Request");
|
|
30
|
+
}
|
|
31
|
+
const m = message;
|
|
32
|
+
const id = typeof m.id === "string" || typeof m.id === "number" ? m.id : null;
|
|
33
|
+
if (m.jsonrpc !== "2.0" || typeof m.method !== "string")
|
|
34
|
+
return error(id, -32600, "Invalid Request");
|
|
35
|
+
if (m.id === undefined)
|
|
36
|
+
return null; // a notification, e.g. "notifications/initialized"
|
|
37
|
+
const params = (typeof m.params === "object" && m.params !== null ? m.params : {});
|
|
38
|
+
switch (m.method) {
|
|
39
|
+
case "initialize": {
|
|
40
|
+
const requested = params.protocolVersion;
|
|
41
|
+
const version = typeof requested === "string" && SUPPORTED_VERSIONS.includes(requested) ? requested : SUPPORTED_VERSIONS[0];
|
|
42
|
+
const clientInfo = params.clientInfo;
|
|
43
|
+
if (typeof clientInfo?.name === "string")
|
|
44
|
+
context.onInitialize?.(clientInfo.name);
|
|
45
|
+
return reply(id, { protocolVersion: version, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: INSTRUCTIONS });
|
|
46
|
+
}
|
|
47
|
+
case "ping":
|
|
48
|
+
return reply(id, {});
|
|
49
|
+
case "tools/list":
|
|
50
|
+
return reply(id, {
|
|
51
|
+
tools: context.tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
52
|
+
});
|
|
53
|
+
case "tools/call": {
|
|
54
|
+
const tool = context.tools.find((t) => t.name === params.name);
|
|
55
|
+
if (tool === undefined)
|
|
56
|
+
return error(id, -32602, `Unknown tool: ${String(params.name)}`);
|
|
57
|
+
const args = params.arguments ?? {};
|
|
58
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
59
|
+
return error(id, -32602, "arguments must be an object");
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return reply(id, await tool.call(args));
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
// Details go to stderr for the human; the model only learns that it failed.
|
|
66
|
+
console.error(`agentcollar mcp: tool ${tool.name} failed: ${e.message}`);
|
|
67
|
+
return reply(id, { content: [{ type: "text", text: `Tool ${tool.name} failed inside AgentCollar.` }], isError: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
default:
|
|
71
|
+
return error(id, -32601, `Method not found: ${m.method}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// AgentCollar MCP server over stdio. Claude Code (or another agent) starts this file and
|
|
2
|
+
// talks to it through stdin/stdout, one JSON message per line.
|
|
3
|
+
// Register: claude mcp add agentcollar -- npx tsx <path>/broker/src/mcp/server.ts
|
|
4
|
+
// IMPORTANT: stdout belongs to the protocol. Anything else printed there breaks the connection,
|
|
5
|
+
// so every log line goes to stderr (console.error).
|
|
6
|
+
import "../env.js";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { handleMessage } from "./protocol.js";
|
|
9
|
+
import { createTools } from "./tools.js";
|
|
10
|
+
const port = Number(process.env.BROKER_PORT ?? 8787);
|
|
11
|
+
let clientName = "mcp-agent"; // replaced by the client's name from "initialize"
|
|
12
|
+
const tools = createTools({ brokerUrl: `http://127.0.0.1:${port}`, agentName: () => clientName });
|
|
13
|
+
const lines = createInterface({ input: process.stdin });
|
|
14
|
+
lines.on("line", (line) => {
|
|
15
|
+
handleMessage(line, { tools, onInitialize: (name) => (clientName = name) })
|
|
16
|
+
.then((reply) => {
|
|
17
|
+
if (reply !== null)
|
|
18
|
+
process.stdout.write(reply + "\n");
|
|
19
|
+
})
|
|
20
|
+
.catch((error) => console.error(`agentcollar mcp: ${error.message}`));
|
|
21
|
+
});
|
|
22
|
+
console.error(`agentcollar MCP server ready (stdio), broker at 127.0.0.1:${port}`);
|