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.
@@ -0,0 +1,124 @@
1
+ // Telegram channel for human approval, using the Bot API directly with fetch.
2
+ // Long polling: we ask Telegram "anything new?" and it holds the request open
3
+ // up to 30 seconds until something happens. No public server or webhook needed.
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { decide, describe } from "./approval.js";
6
+ // One Bot API call. The bot token is part of the URL, so we never print the URL.
7
+ async function callApi(config, method, params) {
8
+ const response = await fetch(`https://api.telegram.org/bot${config.botToken}/${method}`, {
9
+ method: "POST",
10
+ headers: { "content-type": "application/json" },
11
+ body: JSON.stringify(params),
12
+ });
13
+ const data = (await response.json());
14
+ if (!data.ok) {
15
+ throw new Error(`Telegram ${method} failed: ${data.description}`);
16
+ }
17
+ return data.result;
18
+ }
19
+ function buttons(mandate) {
20
+ if (mandate.status === "pending") {
21
+ return [[
22
+ { text: "✅ Approve", callback_data: `approve:${mandate.id}` },
23
+ { text: "❌ Deny", callback_data: `deny:${mandate.id}` },
24
+ ]];
25
+ }
26
+ if (mandate.status === "approved" && !mandate.revoked) {
27
+ return [[{ text: "🛑 Revoke", callback_data: `revoke:${mandate.id}` }]];
28
+ }
29
+ return []; // denied or revoked: nothing left to press
30
+ }
31
+ function statusLine(mandate) {
32
+ if (mandate.revoked)
33
+ return "🛑 Revoked";
34
+ if (mandate.status === "approved")
35
+ return "✅ Approved";
36
+ if (mandate.status === "denied")
37
+ return "❌ Denied";
38
+ return "⏳ Waiting for your decision";
39
+ }
40
+ // Which Telegram message shows which mandate, so it can be edited later (e.g. on timeout).
41
+ const sentMessages = new Map(); // mandate id -> message
42
+ // Sent when an agent asks for a mandate. Only the public id goes to Telegram, never the token.
43
+ // Plain text (no parse_mode): the agent wrote the task text, so we do not let it format anything.
44
+ export async function sendApprovalRequest(config, mandate) {
45
+ const message = (await callApi(config, "sendMessage", {
46
+ chat_id: config.approverId, // in a private chat, chat id = user id
47
+ text: `🔐 Mandate request\n\n${describe(mandate)}\n\n${statusLine(mandate)}`,
48
+ reply_markup: { inline_keyboard: buttons(mandate) },
49
+ }));
50
+ sentMessages.set(mandate.id, { chatId: config.approverId, messageId: message.message_id });
51
+ }
52
+ // The mandate ended outside Telegram: the message must not keep showing live buttons.
53
+ async function closeMessage(config, mandate, finalLine) {
54
+ const sent = sentMessages.get(mandate.id);
55
+ if (sent === undefined)
56
+ return; // this mandate was never shown in Telegram
57
+ sentMessages.delete(mandate.id);
58
+ await callApi(config, "editMessageText", {
59
+ chat_id: sent.chatId,
60
+ message_id: sent.messageId,
61
+ text: `🔐 Mandate request\n\n${describe(mandate)}\n\n${finalLine}`,
62
+ reply_markup: { inline_keyboard: [] },
63
+ });
64
+ }
65
+ // Nobody answered in time.
66
+ export async function notifyTimedOut(config, mandate) {
67
+ await closeMessage(config, mandate, "⌛ Timed out: no answer in 10 minutes");
68
+ }
69
+ // Revoked from the terminal (npm run revoke) or over HTTP.
70
+ export async function notifyRevoked(config, mandate) {
71
+ await closeMessage(config, mandate, "🛑 Revoked");
72
+ }
73
+ // A button was pressed.
74
+ async function handleButton(config, query) {
75
+ // Anyone can find the bot and press buttons in a forwarded message.
76
+ // Only the approver's presses count.
77
+ if (query.from.id !== config.approverId) {
78
+ console.log(`Telegram: ignored a button press from user ${query.from.id} (not the approver)`);
79
+ await callApi(config, "answerCallbackQuery", { callback_query_id: query.id, text: "No access" });
80
+ return;
81
+ }
82
+ const [action, id] = (query.data ?? "").split(":");
83
+ const decision = action;
84
+ const mandate = ["approve", "deny", "revoke"].includes(action) && id ? decide(id, decision, "telegram") : undefined;
85
+ // Telegram shows a spinner on the button until we answer.
86
+ await callApi(config, "answerCallbackQuery", {
87
+ callback_query_id: query.id,
88
+ text: mandate ? statusLine(mandate) : "Already decided, or the mandate is gone",
89
+ });
90
+ // Update the message: new status line, new buttons (e.g. "Revoke" after approval).
91
+ if (mandate && query.message) {
92
+ await callApi(config, "editMessageText", {
93
+ chat_id: query.message.chat.id,
94
+ message_id: query.message.message_id,
95
+ text: `🔐 Mandate request\n\n${describe(mandate)}\n\n${statusLine(mandate)}`,
96
+ reply_markup: { inline_keyboard: buttons(mandate) },
97
+ });
98
+ }
99
+ }
100
+ // Runs forever in the background of the server.
101
+ export async function startTelegramPolling(config) {
102
+ const me = (await callApi(config, "getMe", {}));
103
+ console.log(`Telegram: bot @${me.username} is listening, approver user id ${config.approverId}`);
104
+ let offset = 0; // "give me updates newer than this one"
105
+ while (true) {
106
+ try {
107
+ const updates = (await callApi(config, "getUpdates", {
108
+ offset,
109
+ timeout: 30, // long polling: wait up to 30 s for something new
110
+ allowed_updates: ["callback_query"],
111
+ }));
112
+ for (const update of updates) {
113
+ offset = update.update_id + 1; // mark as handled, even if handling fails
114
+ if (update.callback_query) {
115
+ await handleButton(config, update.callback_query);
116
+ }
117
+ }
118
+ }
119
+ catch (error) {
120
+ console.error(`Telegram: ${error.message}, retrying in 5 s`);
121
+ await sleep(5000);
122
+ }
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,10 +1,55 @@
1
1
  {
2
2
  "name": "agentcollar",
3
- "version": "0.0.1",
4
- "description": "Reserved for AgentCollar (https://github.com/ank8dev/agentcollar). The first real release is published from GitHub Actions with provenance.",
3
+ "version": "0.2.0",
4
+ "description": "A small local broker between your AI agents and your accounts: short-lived, task-scoped mandates that you approve in Telegram, instead of passwords.",
5
+ "keywords": [
6
+ "ai-agents",
7
+ "mcp",
8
+ "security",
9
+ "gmail",
10
+ "telegram",
11
+ "claude-code",
12
+ "audit-log",
13
+ "least-privilege"
14
+ ],
5
15
  "homepage": "https://ank8dev.github.io/agentcollar/",
6
- "repository": { "type": "git", "url": "git+https://github.com/ank8dev/agentcollar.git" },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/ank8dev/agentcollar.git",
19
+ "directory": "broker"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/ank8dev/agentcollar/issues"
23
+ },
7
24
  "author": "ank8dev",
8
- "license": "MIT",
9
- "files": ["README.md"]
25
+ "license": "SEE LICENSE IN LICENSE",
26
+ "type": "module",
27
+ "bin": {
28
+ "agentcollar": "dist/cli.js",
29
+ "agcl": "dist/cli.js"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "prepare": "npm run build",
42
+ "demo": "tsx src/demo.ts",
43
+ "server": "tsx src/server.ts",
44
+ "agent": "tsx src/agent.ts",
45
+ "setup": "tsx src/cli.ts setup",
46
+ "revoke": "tsx src/cli.ts revoke",
47
+ "test": "AGENTCOLLAR_HOME=${TMPDIR:-/tmp}/agentcollar-test-home tsx --test test/*.test.ts",
48
+ "typecheck": "tsc --noEmit"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^26.6.4",
52
+ "tsx": "^4.23.15",
53
+ "typescript": "^7.0.2"
54
+ }
10
55
  }