agentcollar 0.0.1 → 0.1.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 ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ank8dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ The MIT license above applies to the source code in this repository.
26
+ It does NOT apply to the brand assets in the brand/ folder (logos, marks and
27
+ hand-drawn illustrations): those are © 2026 ank8dev, all rights reserved.
package/README.md CHANGED
@@ -1,5 +1,83 @@
1
- # agentcollar
1
+ # AgentCollar
2
2
 
3
- Name reserved for [AgentCollar](https://github.com/ank8dev/agentcollar).
4
- This version contains no code. Real releases (0.1.0 and later) are built and published by
5
- GitHub Actions with npm provenance.
3
+ **Let agents work. Keep the keys.** A small broker that runs on your own computer, between your AI
4
+ agents and your accounts. Agents get a short-lived, task-scoped *mandate* that you approve in
5
+ Telegram — never your password or tokens.
6
+
7
+ > **Hobby project. Use at your own risk. Do not connect accounts you can't afford to lose.**
8
+ > Status: early. Gmail is still **fake** (an in-memory inbox); real Gmail is next.
9
+
10
+ ## Install
11
+
12
+ Requires **Node.js 22+**.
13
+
14
+ ```bash
15
+ npx agentcollar # try it without installing
16
+ npm install -g agentcollar # or install: then the short name `agcl` works too
17
+ ```
18
+
19
+ `agcl` and `agentcollar` are the same program: `agcl watch` == `agentcollar watch`.
20
+
21
+ ## First run
22
+
23
+ ```bash
24
+ agcl
25
+ ```
26
+
27
+ The intro plays, then the setup wizard connects **your own** Telegram bot (create one with
28
+ [@BotFather](https://t.me/BotFather); the wizard takes your user id from the Start message, with a
29
+ one-time code), then the broker starts on `127.0.0.1:8787`.
30
+
31
+ ## Commands
32
+
33
+ | Command | What it does |
34
+ |---|---|
35
+ | `agcl` | intro → setup (if not set up yet) → broker |
36
+ | `agcl setup` | connect your Telegram bot |
37
+ | `agcl server` | start the broker |
38
+ | `agcl watch` | live screen: every agent request and which of the 6 checks passed or failed |
39
+ | `agcl mandates` | mandates of the running broker: state, time left, actions left |
40
+ | `agcl logs [--agent <name>] [--denied] [--today]` | the audit log, filtered |
41
+ | `agcl revoke <id>` | kill switch: revoke a mandate instantly |
42
+ | `agcl mcp` | MCP server (stdio) for Claude Code and other agents |
43
+
44
+ Flags: `--no-intro`, `-h` / `--help`.
45
+
46
+ ## Use it from Claude Code (MCP)
47
+
48
+ ```bash
49
+ claude mcp add agentcollar -- npx -y agentcollar mcp
50
+ ```
51
+
52
+ Tools: `request_mandate`, `mandate_status`, `gmail_read_inbox`, `gmail_create_draft`, `gmail_send`.
53
+ Every call goes through the broker with a mandate token. The token stays inside the MCP server and
54
+ is never shown to the model.
55
+
56
+ ## How a request is checked
57
+
58
+ Every request runs 6 checks in order; the first failure stops it:
59
+
60
+ 1. the token is known → `401`
61
+ 2. a human approved the mandate → `403`
62
+ 3. not expired → `403`
63
+ 4. not revoked → `403`
64
+ 5. this action is in the mandate → `403`
65
+ 6. the action limit is not reached → `429`
66
+
67
+ ## Security
68
+
69
+ - Listens on `127.0.0.1` only; refuses browser requests (`Host` check against DNS rebinding,
70
+ no `Origin`, JSON only).
71
+ - **Approval is never possible over HTTP or through files**: only in Telegram (only your user id)
72
+ or in the broker's own terminal. A local agent cannot approve itself.
73
+ - Your data lives in `~/.agentcollar/` (folder `700`, files `600`): `.env`, `audit.log`,
74
+ `mandates.json` (no tokens in it).
75
+ - Append-only audit log; text from agents is escaped and stripped of terminal control characters.
76
+ - **No runtime dependencies.** Published from GitHub Actions with npm provenance.
77
+
78
+ ## License
79
+
80
+ Code: MIT. The AgentCollar brand assets (logo, the intro medallion derived from it) are
81
+ © ank8dev, all rights reserved. See [LICENSE](LICENSE).
82
+
83
+ Source, roadmap and full docs: <https://github.com/ank8dev/agentcollar>
@@ -0,0 +1,67 @@
1
+ // Phase 3: a human decides about every mandate request.
2
+ // Two channels: Telegram (if configured) or the terminal where the server runs.
3
+ import { createInterface } from "node:readline/promises";
4
+ import { writeAudit } from "./audit.js";
5
+ import { approve, deny, findStalePending, revoke } from "./mandate.js";
6
+ // The single place where a human decision is applied and written to the audit log.
7
+ // Telegram and the terminal both end up here.
8
+ export function decide(id, decision, by) {
9
+ const mandate = decision === "approve" ? approve(id) : decision === "deny" ? deny(id) : revoke(id);
10
+ if (mandate !== undefined) {
11
+ writeAudit(mandate.agent, `mandate.${decision}`, decision === "approve", `${decision} by ${by}, mandate ${id}`);
12
+ }
13
+ return mandate;
14
+ }
15
+ // Fail closed: a request nobody answered in time is denied, never left hanging.
16
+ // Returns the mandates it denied, so the server can update their Telegram messages.
17
+ export function denyStalePending(maxAgeMs, now = Date.now()) {
18
+ const denied = [];
19
+ for (const mandate of findStalePending(maxAgeMs, now)) {
20
+ const result = decide(mandate.id, "deny", "timeout");
21
+ if (result !== undefined)
22
+ denied.push(result);
23
+ }
24
+ return denied;
25
+ }
26
+ // Text written by the agent (its name, the task) is shown to the human, so a bad agent
27
+ // could try to fake lines like "Действия: gmail.read" with line breaks, or hide text with
28
+ // invisible / right-to-left characters. We squash it into one short, plain line.
29
+ export function oneLine(text, max) {
30
+ const plain = text
31
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ") // line breaks, control chars
32
+ .replace(/[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "") // invisible, direction tricks
33
+ .replace(/\s+/g, " ")
34
+ .trim();
35
+ return plain.length > max ? plain.slice(0, max) + "…" : plain;
36
+ }
37
+ // Human-readable summary, used in the terminal and in Telegram.
38
+ // The facts the broker enforces come FIRST; the agent's own words come last, in quotes.
39
+ export function describe(mandate) {
40
+ const lines = [];
41
+ if (mandate.allowedActions.some((action) => action.endsWith(".send"))) {
42
+ lines.push("⚠️ Агент просит право ОТПРАВЛЯТЬ от твоего имени");
43
+ }
44
+ lines.push(`Действия: ${mandate.allowedActions.join(", ")}`, `Срок: ${mandate.expiresInSeconds} с после одобрения, лимит ${mandate.limit}`, `id: ${mandate.id}`, `Агент: ${oneLine(mandate.agent, 64)}`, `Задача (слова агента): «${oneLine(mandate.task, 200)}»`);
45
+ return lines.join("\n");
46
+ }
47
+ // Terminal channel. Questions are asked one at a time: if two agents ask at once,
48
+ // the second question waits until the first one is answered.
49
+ let queue = Promise.resolve();
50
+ export function askInTerminal(mandate) {
51
+ queue = queue.then(async () => {
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
53
+ const answer = await rl.question(`\nНовый запрос мандата:\n${describe(mandate)}\nОдобрить? [y/N] `);
54
+ rl.close();
55
+ console.log(applyTerminalAnswer(mandate, answer));
56
+ });
57
+ }
58
+ // Applies the human's terminal answer and says what REALLY happened.
59
+ // The question may have waited so long that the mandate already timed out
60
+ // (or was decided in Telegram): then the answer changes nothing, and we say so.
61
+ export function applyTerminalAnswer(mandate, answer) {
62
+ const decision = answer.trim().toLowerCase() === "y" ? "approve" : "deny";
63
+ if (decide(mandate.id, decision, "terminal") === undefined) {
64
+ return " ⌛ уже решено (время вышло или ответили в Telegram), ответ не применён";
65
+ }
66
+ return decision === "approve" ? " ✅ одобрен" : " ❌ отклонён";
67
+ }
@@ -0,0 +1,37 @@
1
+ // Reads data/audit.log back into objects, for `agentcollar logs` and `agentcollar watch`.
2
+ // Line format (see audit.ts): time | "agent" | "action" | ALLOWED|DENIED | "reason" [| code]
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ // A JSON string: quote, then any characters except an unescaped quote, then quote.
5
+ // Matching whole strings (not splitting on " | ") keeps a " | " inside the text in one piece.
6
+ const QUOTED = String.raw `"(?:[^"\\]|\\.)*"`;
7
+ const LINE = new RegExp(String.raw `^(\S+) \| (${QUOTED}) \| (${QUOTED}) \| (ALLOWED|DENIED) \| (${QUOTED})(?: \| ([a-z_]+))?$`);
8
+ // The very first lines (before escaping was added in step 4.1) had no quotes at all.
9
+ const OLD_LINE = /^(\S+) \| ([^|"]+) \| ([^|"]+) \| (ALLOWED|DENIED) \| ([^|"]*)$/;
10
+ export function parseAuditLine(line) {
11
+ const match = LINE.exec(line);
12
+ if (match !== null) {
13
+ const [, time, agent, action, verdict, reason, code] = match;
14
+ return {
15
+ time: time,
16
+ agent: JSON.parse(agent),
17
+ action: JSON.parse(action),
18
+ allowed: verdict === "ALLOWED",
19
+ reason: JSON.parse(reason),
20
+ code: code ?? null,
21
+ };
22
+ }
23
+ const old = OLD_LINE.exec(line);
24
+ if (old !== null) {
25
+ const [, time, agent, action, verdict, reason] = old;
26
+ return { time: time, agent: agent, action: action, allowed: verdict === "ALLOWED", reason: reason, code: null };
27
+ }
28
+ return null; // not a log line: skip it rather than guess
29
+ }
30
+ export function readAuditLog(file) {
31
+ if (!existsSync(file))
32
+ return [];
33
+ return readFileSync(file, "utf8")
34
+ .split("\n")
35
+ .map(parseAuditLine)
36
+ .filter((entry) => entry !== null);
37
+ }
package/dist/audit.js ADDED
@@ -0,0 +1,23 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { auditLogFile, ensureHome } from "./paths.js";
3
+ // ~/.agentcollar/audit.log (see paths.ts). Re-exported for the modules that read the log.
4
+ export { auditLogFile };
5
+ // Text that came from outside (agent name, action) could contain a line break
6
+ // and fake a second log line. JSON.stringify wraps it in quotes and turns a line
7
+ // break into a visible \n, so one event is always exactly one line.
8
+ function safe(text) {
9
+ return JSON.stringify(text);
10
+ }
11
+ // Appends one line per event: every check, and every human decision.
12
+ // We only ever add lines, never change or delete them.
13
+ // The token is NOT written: it is a secret, and the log is not.
14
+ // `code` (only for checks) says which check decided, e.g. "expired": `agentcollar watch` shows it.
15
+ export function writeAudit(agent, action, allowed, reason, code) {
16
+ ensureHome(); // ~/.agentcollar/ with rights 700
17
+ const time = new Date().toISOString(); // e.g. 2026-10-04T07:00:00.000Z (UTC)
18
+ const verdict = allowed ? "ALLOWED" : "DENIED";
19
+ const fields = [time, safe(agent), safe(action), verdict, safe(reason)];
20
+ if (code !== undefined)
21
+ fields.push(code); // our own fixed word: needs no quoting
22
+ appendFileSync(auditLogFile, fields.join(" | ") + "\n", { mode: 0o600 }); // 600 when the file is created
23
+ }
package/dist/check.js ADDED
@@ -0,0 +1,52 @@
1
+ import { writeAudit } from "./audit.js";
2
+ import { mandates, mandatesChanged } from "./mandate.js";
3
+ // The order of the checks. A failure code tells how far a request got: "expired" = check 3.
4
+ export const CHECK_ORDER = ["unknown_token", "not_approved", "expired", "revoked", "action_not_allowed", "limit_reached"];
5
+ // The one function the outside world calls: decide, then write it down.
6
+ // Every answer goes to the audit log, allowed or denied.
7
+ export function check(token, action) {
8
+ const result = runChecks(token, action);
9
+ // For an unknown token there is no mandate, so no agent name either.
10
+ const agent = mandates.get(token)?.agent ?? "unknown";
11
+ writeAudit(agent, action, result.allowed, result.reason, result.code);
12
+ return result;
13
+ }
14
+ function deny(code, reason) {
15
+ return { allowed: false, code, reason };
16
+ }
17
+ // Runs the checks in order. The first failed check stops everything (early return).
18
+ function runChecks(token, action) {
19
+ // 1. Token is known: we issued it ourselves
20
+ const mandate = mandates.get(token);
21
+ if (mandate === undefined) {
22
+ return deny("unknown_token", "unknown token");
23
+ }
24
+ // 2. A human approved it (Phase 3). Must come before "expired":
25
+ // a pending mandate has no expiry time yet.
26
+ if (mandate.status === "pending") {
27
+ return deny("not_approved", "mandate is waiting for human approval");
28
+ }
29
+ if (mandate.status === "denied") {
30
+ return deny("not_approved", "mandate was denied (by the human, or no answer in time)");
31
+ }
32
+ // 3. Not expired
33
+ if (Date.now() > mandate.expiresAt) {
34
+ return deny("expired", "mandate expired");
35
+ }
36
+ // 4. Not revoked by the human (kill switch)
37
+ if (mandate.revoked) {
38
+ return deny("revoked", "mandate revoked");
39
+ }
40
+ // 5. This action is on the list
41
+ if (!mandate.allowedActions.includes(action)) {
42
+ return deny("action_not_allowed", `action "${action}" is not allowed`);
43
+ }
44
+ // 6. Limit not reached
45
+ if (mandate.used >= mandate.limit) {
46
+ return deny("limit_reached", `limit of ${mandate.limit} actions reached`);
47
+ }
48
+ // All checks passed. Only allowed actions use up the limit.
49
+ mandate.used += 1;
50
+ mandatesChanged();
51
+ return { allowed: true, code: "ok", reason: "ok" };
52
+ }
@@ -0,0 +1,39 @@
1
+ // How one audit entry looks on screen. Shared by `agentcollar logs` and `agentcollar watch`.
2
+ import { styleText } from "node:util";
3
+ import { oneLine } from "../approval.js";
4
+ import { CHECK_ORDER } from "../check.js";
5
+ // ✓ passed, ✗ failed here, · not reached. Empty for human decisions and old lines without a code.
6
+ export function checkMarks(entry) {
7
+ if (entry.code === null)
8
+ return "";
9
+ const failedAt = entry.code === "ok" ? CHECK_ORDER.length : CHECK_ORDER.indexOf(entry.code);
10
+ return CHECK_ORDER.map((_, i) => (i < failedAt ? "✓" : i === failedAt ? "✗" : "·")).join(" ");
11
+ }
12
+ const pad = (n) => String(n).padStart(2, "0");
13
+ export function isSameLocalDay(a, b) {
14
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
15
+ }
16
+ export function isToday(iso, now = new Date()) {
17
+ return isSameLocalDay(new Date(iso), now);
18
+ }
19
+ // Local time: "15:42:07", or "2026-10-04 15:42:07" when the date matters.
20
+ export function localTime(iso, showDate) {
21
+ const d = new Date(iso);
22
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
23
+ return showDate ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}` : time;
24
+ }
25
+ // Agent names, actions and reasons come from agents. Printed raw, a text like "\u001b[2J" would
26
+ // be a COMMAND to the terminal (clear screen, change title...). oneLine() strips control characters.
27
+ export function formatEntry(entry, options) {
28
+ const time = styleText("dim", localTime(entry.time, options.showDate));
29
+ const agent = oneLine(entry.agent, 24).padEnd(16);
30
+ const action = oneLine(entry.action, 32);
31
+ const reason = styleText("dim", oneLine(entry.reason, 120));
32
+ if (entry.action.startsWith("mandate.")) {
33
+ // a human decision (approve / deny / revoke) or the relay fallback: one yellow line
34
+ return `${time} ${styleText("yellow", `${agent} ${action.padEnd(18)} ${oneLine(entry.reason, 120)}`)}`;
35
+ }
36
+ const verdict = entry.allowed ? styleText("green", "ALLOWED") : styleText("red", "DENIED ");
37
+ const marks = checkMarks(entry).padEnd(11);
38
+ return `${time} ${agent} ${action.padEnd(18)} ${verdict} ${marks} ${reason}`;
39
+ }
@@ -0,0 +1,25 @@
1
+ // GENERATED by tools/gen-intro.ts from brand/logo/agentcollar-mark-black.png. Do not edit by hand.
2
+ // Derived from the AgentCollar mark: © 2026 ank8dev, all rights reserved (NOT covered by the MIT license).
3
+ // The medallion, 48×48 dots (24 columns × 12 rows), turned 0°, 30°, … 330° clockwise.
4
+ export const SPIN = [
5
+ ["", " ⢀⣤⣤⣴⣶⣶⣶⣦⣄⡀", " ⣤⣾⣵⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄", " ⢠⣾⣿⣿⣿⠿⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀", " ⢀⣿⢻⣿⣿⡟ ⡀⠹⣿⣿⡿⠉⢀⡀⠘⣿⣿⣷⡀", " ⢸⣿⣾⣿⡿ ⣸⣧ ⢻⣿⠁ ⣿⣿⣿⣿⣿⣿⡇", " ⢸⣿⣿⣿⠃ ⣀⣀⡀⠈⣿ ⠘⣿⣿⡿⣿⣿⣿⡇", " ⠈⣿⣿⡏ ⣼⣿⣿⣷ ⢸⣧⡀⠉⠉⢀⣼⣿⣿⠇", " ⠘⣿⣷⣾⣿⣿⣿⣿⣷⣾⣿⣿⣶⣾⣿⡿⣻⠏", " ⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁", " ⠉⠛⠳⠶⠿⠿⠿⠟⠛⠁", ""],
6
+ ["", " ⣀⣤⣴⣶⣒⣶⣶⣤⣄⣀", " ⢀⣤⡾⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄", " ⢠⣾⣯⣾⣿⣿⠿⠋⠉⢹⣿⣿⣿⣿⣿⣿⣷⡀", " ⢀⣿⣿⣿⡿⠟⠁⣠⡆ ⣾⣿⣿⣿⣿⣿⣿⣿⣷", " ⢸⣿⡿⠋ ⣀⡀⠉⠁⢀⣿⡿⠋⠉⣀⠉⢻⣿⣿⡆", " ⢸⣿⣦⣤⣾⣿⣿⡇ ⣸⡏ ⣠⣾⣿⣤⣼⣿⣏⡇", " ⠘⣿⣿⣿⣿⣿⣿⡇⢀⣿ ⢰⣿⣿⣿⣿⣿⣿⣿⠁", " ⠘⣿⡿⣿⣿⣿⣿⣿⣿⣄ ⠉⠁⣹⣿⣿⣿⠃", " ⠈⠻⢮⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁", " ⠈⠛⠻⠿⠿⠿⠿⠿⠚⠋⠁", ""],
7
+ ["", " ⣀⣤⣤⣶⣶⣶⣶⣦⣤⡀", " ⢀⣴⣾⣿⣿⣶⣶⣿⣿⣿⣿⣷⣾⣷⣄", " ⢠⣿⣿⣿⡿⠿⠿⠛⠛⠛⠉⠛⣿⣿⣿⣿⣷⡄", " ⢠⣿⣿⣀⣀⣀⣀ ⢲⡶⠋⢀⣾⣿⣿⣿⣿⣿⣷⡀", " ⢸⣿⣿⣿⣿⣿⣿⡦ ⣴⣿⣿⣿⣿⣿⣿⣿⣿⡇", " ⢸⡿⣿⣿⣿⣿⠋ ⣠⡾⠋⠉⠉⠁⠉⠻⣿⣿⣿⡇", " ⠈⣷⣻⣿⣿⣿⣶⣿⠃⢀⣴⣾⣿⣿⡃⢠⣿⣿⡿", " ⠈⢿⣿⣿⣿⣿⣿⡀⠘⠿⢿⣿⣿⣿⣿⣿⡿⠁", " ⠻⢿⣿⣿⣿⣿⣶⣤⣼⣿⣿⣿⡿⠋", " ⠉⠛⠿⢭⣿⣿⣿⠿⠛⠉", ""],
8
+ ["", " ⣀⣤⣴⣶⣶⣶⣶⣦⣤⣀", " ⢀⣤⣿⡿⠿⢿⣿⣿⣿⣷⣶⣿⣿⣷⣤", " ⢠⣿⣿⣿⣷⣤⣀ ⠉⠙⠛⠿⣿⣿⣿⣿⡳⡀", " ⢀⡿⣿⣿⣿⣿⣿⣿⡇ ⣷⠶⠄ ⢘⣿⣿⣿⣿", " ⢸⣧⣿⣿⣿⡿⠛⠋⠁⢀⣀⣤⣴⣾⣿⣿⣿⣿⣿⡆", " ⢸⣿⣿⣿⣿⣷⣶⠶⠛⠛⠛⠻⢿⣿⣿⣿⣿⣿⣿⡇", " ⠈⣿⣿⣿⣿⡟⠁⢠⣤⣶⣤⣤⡀⠘⣿⣿⣿⣿⡿⠁", " ⠈⣿⣿⣿⣷⡀⠘⢿⣿⣿⣿⠁⣀⣿⣿⣿⡿⠁", " ⠈⠻⣝⣿⣿⣶⣿⣿⣿⣿⣿⣿⣿⡿⠋", " ⠈⠙⠻⠿⠿⠿⠿⠿⠟⠋⠁", ""],
9
+ ["", " ⣀⣤⣶⣶⣶⣶⣶⣦⣤⣀", " ⢀⣴⢿⣿⣿⣿⣿⠁⠙⢿⣿⣿⣿⢷⣦", " ⢀⣾⣵⣿⣿⣿⣿⣿⣷⡄ ⠹⣿⣿⣷⣹⣷⡄", " ⢀⣿⣿⣿⣿⣿⠿⠿⠿⠿⠁⢠⣄⠈⢻⣿⣿⣿⣿⡀", " ⢸⣿⣿⣿⣿⣿⣦⣤⣄⣀⡀⠈⠉⠁ ⢹⣿⣿⡟⡇", " ⢸⣿⣿⣿⠋ ⣀⡀⠉⠙⢿⣿⣿⣷⣶⣾⣿⣿⣿⡇", " ⠘⣟⣿⣿ ⠸⣿⣿⣷⣄ ⢹⣿⣿⣿⣿⣿⣿⡿", " ⠹⣿⣿⣷⣾⣿⣿⣿⠛⠃⢠⣿⣿⣿⣿⣿⡿⠃", " ⠙⠻⣿⣿⣿⣿⣿⣶⣶⣿⣿⣿⣿⡿⠋", " ⠉⠛⠻⠯⠽⠿⠟⠛⠋⠁", ""],
10
+ ["", " ⢀⣤⡴⢶⣶⣶⣶⣶⣤⣀", " ⢀⣤⣾⣿⣷⣿⣿⣿⣿⣿⡟⠛⣿⣿⣦⡀", " ⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ⢿⣿⣿⣷⡄", " ⢠⣿⣿⣿⣿⣿⣿⣧ ⠙⢿⠟⠃ ⢸⣿⣿⡟⣿", " ⣾⣼⣿⡟⠁⣀⡀⠉⢷⣄ ⢶⡇ ⣿⣿⣧⣿⡇", " ⣿⣿⣿⣀⣸⣿⣿⣆ ⢹⣿⣦⡀⠙ ⢻⣿⣿⣿⡇", " ⠘⣿⣿⣿⣿⣿⣿⣿ ⠸⣿⣿⣿⣷⣤⣿⣿⡿⣿⠁", " ⠘⢿⣿⣿⣿⣁⠉⢀⣼⣿⣿⣿⣿⣿⣿⣿⡷⠁", " ⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋", " ⠈⠙⠛⠿⠿⠿⠿⠟⠋⠉", ""],
11
+ ["", " ⢀⣤⣴⣶⣶⣶⠶⢦⣤⣀", " ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦", " ⣰⣯⣾⣿⡿⠿⣿⣿⡿⢿⣿⣿⣿⣿⡿⢿⣿⡄", " ⢰⣿⣿⡟⠁⣀⣀⠈⢻⡇ ⢿⣿⣿⡟ ⣸⣿⣿⡀", " ⢸⣿⣿⣿⣾⣿⣿⡄ ⣿⡀⠈⠉⠉ ⢠⣿⣿⣿⡇", " ⢸⣿⣿⣿⣿⣿⣿ ⢀⣿⣧ ⢻⡏ ⣾⣿⡿⣿⡇", " ⠈⢿⣿⣿⡄⠈⠁⣀⣾⣿⣿⣆⠈ ⣼⣿⣿⣧⣿⠁", " ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣶⣿⣿⣿⡿⠃", " ⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⡿⠛", " ⠈⠙⠻⠿⠿⠿⠟⠛⠛⠁", ""],
12
+ ["", " ⢀⣠⡤⣶⣶⣶⣶⣶⣦⣤⡀", " ⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡳⣦⡀", " ⢠⣿⣿⣿⣏⢀⣀ ⠙⣿⣿⣿⣿⣿⣿⣾⣿⡄", " ⢀⣿⣿⣿⣿⣿⣿⣿⠇ ⣿⠁⢸⣿⣿⣿⣿⣿⣿⡄", " ⢸⣹⣿⡟⠛⣿⡿⠋ ⣸⡏ ⢸⣿⣿⡿⠛⠻⣿⡇", " ⠸⣿⣿⣧⣀⠉⣀⣠⣾⣿⠁⢀⣀⠈⠉ ⣠⣾⣿⡇", " ⢿⣿⣿⣿⣿⣿⣿⣿⡿ ⠸⠋⢀⣴⣾⣿⣿⣿⠁", " ⠈⢿⣿⣿⣿⣿⣿⣿⣇⣀⣠⣶⣿⣿⡿⣻⡿⠃", " ⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡾⠛⠁", " ⠉⠙⠛⠿⠿⠭⠿⠟⠛⠉", ""],
13
+ ["", " ⣀⣤⣶⣿⣿⣿⣓⣶⣤⣀", " ⣠⣾⣿⣿⣿⡟⠛⠿⣿⣿⣿⣿⣷⣦", " ⢀⣾⣿⣿⣿⣿⣿⣷⣶⡄⠈⣿⣿⣿⣿⣿⣷⡀", " ⣾⣿⣿⠃⢨⣿⣿⡿⠟⠁⢠⣿⠿⣿⣿⣿⣯⢿⡀", " ⢸⣿⣿⣿⣦⣀⢀⣀⣀⣠⡾⠋ ⣠⣿⣿⣿⣿⣾⡇", " ⢸⣿⣿⣿⣿⣿⣿⣿⣿⠟ ⠺⣿⣿⣿⣿⣿⣿⡇", " ⠈⢿⣿⣿⣿⣿⣿⡿⠁⣠⠾⠧ ⠉⠉⠉⠉⣿⣿⠃", " ⠘⢿⣿⣿⣿⣿⣤⣀⣤⣤⣤⣶⣶⣾⣿⣿⣿⠃", " ⠙⢿⡿⢿⣿⣿⣿⣿⠿⠿⣿⣿⡿⠟⠁", " ⠈⠛⠻⠿⠿⠿⠿⠛⠛⠉", ""],
14
+ ["", " ⢀⣠⣴⣶⣶⣶⣶⣶⣦⣄⡀", " ⣠⣾⣿⣿⣿⣿⣿⣿⣿⠿⣿⣿⣝⣦⡀", " ⢀⣾⣿⣿⣿⠉⢀⣿⣿⣿⣷⡄⠈⢿⣿⣿⣿⡀", " ⢀⣾⣿⣿⣿⣿⡄⠈⠛⠛⠿⠛⠃⢀⣼⣿⣿⣿⣿⡀", " ⢸⣿⣿⣿⣿⣿⣿⣷⣦⣤⣤⣤⠶⠿⢿⣿⣿⣿⣿⡇", " ⠸⣿⣿⣿⣿⣿⡿⠟⠛⠉⠁⢀⣠⣤⣾⣿⣿⣿⢻⡇", " ⣿⣿⣿⣿⡅ ⠐⠶⢿ ⢸⣿⣿⣿⣿⣿⣿⣾⠁", " ⠈⢮⣿⣿⣿⣿⣶⣤⣄⣀ ⠉⠛⢿⣿⣿⣿⠃", " ⠛⢿⣿⣿⠿⢿⣿⣿⣿⣷⣶⣾⣿⠛⠁", " ⠉⠛⠻⠿⠿⠿⠿⠟⠛⠉", ""],
15
+ ["", " ⢀⣠⣤⣴⣶⣖⣲⣦⣤⣀", " ⣠⣾⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⣿⣦⣄", " ⢠⣾⣿⣿⣿⣿⣿⠃⢠⣤⣿⣿⣿⡿⢿⣿⣿⣆", " ⣾⣿⣿⣿⣿⣿⣿⣇ ⠙⢿⣿⣿⡆ ⣿⣿⣽⡄", " ⢸⣿⣿⣿⡿⠿⢿⣿⣿⣷⣄⣀⠈⠉ ⣠⣿⣿⣿⡇", " ⢸⣼⣿⣿⣇ ⢀⣀⡀⠈⠉⠙⠛⠻⣿⣿⣿⣿⣿⡇", " ⠈⣿⣿⣿⣿⣧⡀⠙⠃⢀⣶⣶⣶⣶⣿⣿⣿⣿⣿⠁", " ⠘⢿⣏⢿⣿⣿⣆ ⠘⢿⣿⣿⣿⣿⣿⢟⡿⠁", " ⠻⢷⣿⣿⣿⣷⣄⢀⣿⣿⣿⣿⣷⠟⠁", " ⠉⠛⠻⠿⠿⠿⠿⠿⠛⠉", ""],
16
+ ["", " ⣀⣠⣴⣶⣶⣶⣶⣤⣄⡀", " ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄", " ⢀⢾⣿⣿⣿⣿⣿⣿⣿⡟⠁⣀⢉⣿⣿⣿⣷⡄", " ⢀⣿⣾⣿⣿⠛⢿⣿⣿⣿⡆ ⣿⣿⣿⣿⣿⣿⣿⡄", " ⢸⣿⣿⣿⣧ ⣄⠈⠻⣿⣇ ⠹⣿⣿⡏⠉⣿⣿⣿", " ⢸⣿⢻⣿⣿ ⢸⠷ ⠙⢷⣀⠈⠉⢀⣼⣿⡟⡿", " ⣿⣼⣿⣿⡇ ⢠⣴⣷⣄ ⢻⣿⣿⣿⣿⣿⣿⠃", " ⠘⢿⣿⣿⣷ ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃", " ⠈⠻⣿⣿⣤⣼⣿⣿⣿⣿⣿⢿⣿⡿⠛⠁", " ⠉⠛⠿⠿⠿⠿⠷⠞⠛⠁", ""],
17
+ ];
18
+ // Upright and smaller (32, 20, 12 dots): the medallion "flies" into the header.
19
+ export const SHRINK = [
20
+ [" ⢀⡀", " ⢀⣴⣾⣿⣿⣿⣿⣷⣦⡀", " ⣴⣿⣿⡟⠻⣿⣿⡿⠟⢿⣿⣆", " ⢠⣿⣿⡟⢠⡆⢹⡿ ⣶⣶⣿⣿⡄", " ⠘⣿⣿⠁⣠⣤ ⢧ ⠿⠟⣿⣿⡇", " ⠹⣧⣴⣿⣿⣧⣼⣷⣤⣶⣿⡟", " ⠙⠿⢿⣿⣿⣿⣿⣿⠟⠋", " ⠉⠉"],
21
+ [" ⣀⣤⣤⣀", " ⢰⣿⡟⢿⣿⠿⢿⡄", " ⣿⡟⢘⡈⡇⣾⢿⣿", " ⠹⣧⣿⣧⣷⣦⣾⠏", " ⠈⠉⠛⠛⠋"],
22
+ [" ⣠⣴⣦⣄", "⠰⡟⡊⡳⢾⡆", " ⠙⠻⠟⠋"],
23
+ ];
24
+ // 8×8 dots: the small mark in the header.
25
+ export const ICON = ["⢠⡶⢶⡄", "⠘⠶⠾⠃"];
@@ -0,0 +1,105 @@
1
+ // The intro, after landing/preloader.js: the AC medallion spins, "AgentCollar" types itself
2
+ // and un-types, then the medallion shrinks into the header. About 1.7 s; any key skips it.
3
+ // The frames are plain text made once from the logo (see tools/gen-intro.ts).
4
+ import { styleText } from "node:util";
5
+ import { ICON, SHRINK, SPIN } from "./intro-frames.js";
6
+ const WORD = "AgentCollar";
7
+ const WELCOME = "Welcome. Let agents work. Keep the keys.";
8
+ const HEIGHT = SPIN[0].length; // every frame has this many lines, so each one overwrites the last
9
+ const WORD_ROW = Math.floor(HEIGHT / 2) - 1;
10
+ const WORD_COLUMN = 28;
11
+ function frame(medallion, word = "", delayMs = 0) {
12
+ const lines = Array.from({ length: HEIGHT }, (_, row) => {
13
+ const left = medallion[row] ?? "";
14
+ return row === WORD_ROW && word !== "" ? left.padEnd(WORD_COLUMN) + word : left;
15
+ });
16
+ return { lines, delayMs };
17
+ }
18
+ // The whole animation as data: easy to test (duration, sizes) and to play.
19
+ export function introTimeline() {
20
+ const upright = SPIN[0];
21
+ const frames = [];
22
+ for (const turned of SPIN)
23
+ frames.push(frame(turned, "", 40)); // one full turn
24
+ frames.push(frame(upright, "", 40)); // settles upright
25
+ for (let i = 1; i <= WORD.length; i++)
26
+ frames.push(frame(upright, WORD.slice(0, i), 45)); // types
27
+ frames[frames.length - 1].delayMs = 250; // holds the full name
28
+ for (let i = WORD.length - 1; i >= 0; i--)
29
+ frames.push(frame(upright, WORD.slice(0, i), 22)); // un-types
30
+ for (const smaller of SHRINK)
31
+ frames.push(frame(smaller, "", 60)); // flies into the header
32
+ frames.push(frame([`${ICON[0]} ${styleText("bold", WORD)}`, `${ICON[1]} ${WELCOME}`]));
33
+ return frames;
34
+ }
35
+ // Only for a person looking at a real terminal: never in pipes, logs, CI, or when asked not to.
36
+ export function shouldShowIntro(c) {
37
+ if (c.noIntro || !c.stdoutTTY)
38
+ return false;
39
+ if (c.env.CI !== undefined || c.env.TERM === "dumb")
40
+ return false;
41
+ if (c.env.NO_COLOR !== undefined && c.env.NO_COLOR !== "")
42
+ return false; // no-color.org rule
43
+ return c.columns >= 72 && c.rows >= HEIGHT + 4;
44
+ }
45
+ // ANSI escape codes used below:
46
+ // ESC[?25l / ESC[?25h hide / show the cursor
47
+ // ESC 7 / ESC 8 save / restore the cursor position
48
+ // ESC[2K clear the current line
49
+ // ESC[J clear from the cursor to the end of the screen
50
+ // ESC[<n>A move the cursor n lines up
51
+ const ESC = "\u001b";
52
+ export async function playIntro(out = process.stdout, input = process.stdin) {
53
+ const frames = introTimeline();
54
+ let skipped = false;
55
+ let ctrlC = false;
56
+ let wake = () => { };
57
+ const showCursor = () => out.write(`${ESC}[?25h`);
58
+ process.once("exit", showCursor); // even if something goes wrong, never leave the cursor hidden
59
+ const onKey = (key) => {
60
+ skipped = true;
61
+ ctrlC = key[0] === 3; // Ctrl+C
62
+ wake();
63
+ };
64
+ if (input.isTTY) {
65
+ input.setRawMode(true);
66
+ input.resume();
67
+ input.on("data", onKey);
68
+ }
69
+ // make room for the frames, go back up, and remember where the block starts
70
+ out.write(`${ESC}[?25l${"\n".repeat(HEIGHT)}${ESC}[${HEIGHT}A${ESC}7`);
71
+ const draw = (lines) => out.write(`${ESC}8${lines.map((line) => `${ESC}[2K${line}`).join("\n")}`);
72
+ for (const f of frames.slice(0, -1)) {
73
+ if (skipped)
74
+ break;
75
+ draw(f.lines);
76
+ await new Promise((resolve) => {
77
+ wake = resolve;
78
+ setTimeout(resolve, f.delayMs);
79
+ });
80
+ }
81
+ // the header stays; everything below it is cleared
82
+ const header = frames[frames.length - 1].lines.filter((line) => line !== "");
83
+ out.write(`${ESC}8${ESC}[J${header.join("\n")}\n\n`);
84
+ showCursor();
85
+ process.off("exit", showCursor);
86
+ if (input.isTTY) {
87
+ input.off("data", onKey);
88
+ input.setRawMode(false);
89
+ input.pause();
90
+ }
91
+ if (ctrlC)
92
+ process.exit(130);
93
+ }
94
+ // Shows the intro only when it makes sense (see shouldShowIntro).
95
+ export async function maybeIntro(noIntro) {
96
+ const context = {
97
+ stdoutTTY: process.stdout.isTTY === true,
98
+ columns: process.stdout.columns ?? 0,
99
+ rows: process.stdout.rows ?? 0,
100
+ env: process.env,
101
+ noIntro,
102
+ };
103
+ if (shouldShowIntro(context))
104
+ await playIntro();
105
+ }
@@ -0,0 +1,46 @@
1
+ // agentcollar logs [--agent <name>] [--denied] [--today] — the audit log, readable and filtered.
2
+ import { readAuditLog } from "../audit-log.js";
3
+ import { auditLogFile } from "../audit.js";
4
+ import { formatEntry, isSameLocalDay } from "./format.js";
5
+ export function parseLogsArgs(args) {
6
+ const filter = { agent: null, denied: false, today: false };
7
+ for (let i = 0; i < args.length; i++) {
8
+ const arg = args[i];
9
+ if (arg === "--denied")
10
+ filter.denied = true;
11
+ else if (arg === "--today")
12
+ filter.today = true;
13
+ else if (arg === "--agent") {
14
+ const name = args[++i];
15
+ if (name === undefined || name.startsWith("--"))
16
+ throw new Error("После --agent нужно имя: --agent <имя>");
17
+ filter.agent = name;
18
+ }
19
+ else
20
+ throw new Error(`Неизвестный флаг: ${arg}. Есть: --agent <имя>, --denied, --today`);
21
+ }
22
+ return filter;
23
+ }
24
+ export function filterEntries(entries, filter, now = new Date()) {
25
+ return entries.filter((entry) => (filter.agent === null || entry.agent === filter.agent) &&
26
+ (!filter.denied || !entry.allowed) &&
27
+ (!filter.today || isSameLocalDay(new Date(entry.time), now)));
28
+ }
29
+ export async function runLogs(args) {
30
+ let filter;
31
+ try {
32
+ filter = parseLogsArgs(args);
33
+ }
34
+ catch (error) {
35
+ console.error(error.message);
36
+ return 1;
37
+ }
38
+ const entries = filterEntries(readAuditLog(auditLogFile), filter);
39
+ if (entries.length === 0) {
40
+ console.log("Записей нет.");
41
+ return 0;
42
+ }
43
+ for (const entry of entries)
44
+ console.log(formatEntry(entry, { showDate: !filter.today }));
45
+ return 0;
46
+ }
@@ -0,0 +1,94 @@
1
+ // agcl <command> [arguments] — one entry point for the broker. `agentcollar` is the same program:
2
+ // both names point to one file, so `agcl watch` == `agentcollar watch`.
3
+ // Each command lives in its own file and is loaded only when it is used.
4
+ import { styleText } from "node:util";
5
+ // Runs forever (until Ctrl+C): the server and the MCP server keep the process alive themselves.
6
+ const forever = () => new Promise(() => { });
7
+ const COMMANDS = {
8
+ setup: {
9
+ usage: "agcl setup",
10
+ summary: "подключить своего Telegram-бота (мастер настройки)",
11
+ run: async () => (await import("./setup.js")).runSetup(),
12
+ },
13
+ server: {
14
+ usage: "agcl server",
15
+ summary: "запустить брокер на 127.0.0.1",
16
+ run: async () => {
17
+ await import("../server.js");
18
+ return forever();
19
+ },
20
+ },
21
+ watch: {
22
+ usage: "agcl watch",
23
+ summary: "живой экран: запросы агентов в реальном времени и 6 проверок",
24
+ run: async (args, flags) => {
25
+ await (await import("./intro.js")).maybeIntro(flags.noIntro);
26
+ return (await import("./watch.js")).runWatch(args);
27
+ },
28
+ },
29
+ mandates: {
30
+ usage: "agcl mandates",
31
+ summary: "мандаты запущенного брокера: статус, сколько осталось времени и действий",
32
+ run: async (args) => (await import("./mandates.js")).runMandates(args),
33
+ },
34
+ logs: {
35
+ usage: "agcl logs [--agent <имя>] [--denied] [--today]",
36
+ summary: "аудит-лог: кто, что, когда, разрешено или нет",
37
+ run: async (args) => (await import("./logs.js")).runLogs(args),
38
+ },
39
+ revoke: {
40
+ usage: "agcl revoke <id>",
41
+ summary: "мгновенно отозвать мандат (kill switch)",
42
+ run: async (args) => (await import("./revoke.js")).runRevoke(args),
43
+ },
44
+ mcp: {
45
+ usage: "agcl mcp",
46
+ summary: "MCP-сервер для Claude Code и других агентов (stdio)",
47
+ run: async () => {
48
+ await import("../mcp/server.js");
49
+ return forever();
50
+ },
51
+ },
52
+ };
53
+ export function parseCommand(argv) {
54
+ const flags = { noIntro: argv.includes("--no-intro") };
55
+ const [name, ...args] = argv.filter((word) => word !== "--no-intro");
56
+ if (name === undefined)
57
+ return { kind: "default", flags };
58
+ if (name === "help" || name === "--help" || name === "-h")
59
+ return { kind: "help" };
60
+ if (!Object.hasOwn(COMMANDS, name))
61
+ return { kind: "unknown", name };
62
+ return { kind: "run", name, args, flags };
63
+ }
64
+ function helpText() {
65
+ const width = Math.max(...Object.values(COMMANDS).map((c) => c.usage.length)) + 3;
66
+ const row = (left, right) => ` ${styleText("cyan", left.padEnd(width))}${right}`;
67
+ return [
68
+ `${styleText("bold", "AgentCollar")} — let agents work, keep the keys.`,
69
+ "",
70
+ row("agcl", "заставка → мастер настройки (если ещё не настроен) → брокер"),
71
+ ...Object.values(COMMANDS).map((c) => row(c.usage, c.summary)),
72
+ "",
73
+ row("--no-intro", "без заставки"),
74
+ row("-h, --help", "эта справка"),
75
+ "",
76
+ styleText("dim", "agcl — короткое имя agentcollar: agcl watch == agentcollar watch"),
77
+ styleText("dim", "Данные: ~/.agentcollar/ (папка доступна только тебе)"),
78
+ ].join("\n");
79
+ }
80
+ export async function runCli(argv) {
81
+ const parsed = parseCommand(argv);
82
+ switch (parsed.kind) {
83
+ case "default":
84
+ return (await import("./start.js")).runStart(parsed.flags);
85
+ case "help":
86
+ console.log(helpText());
87
+ return 0;
88
+ case "unknown":
89
+ console.error(`${styleText("red", `Неизвестная команда: ${parsed.name}`)}\n\n${helpText()}`);
90
+ return 1;
91
+ case "run":
92
+ return COMMANDS[parsed.name].run(parsed.args, parsed.flags);
93
+ }
94
+ }