aimessage-app 1.0.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,254 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync } = require("child_process");
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+
8
+ const cmd = process.argv[2];
9
+
10
+ if (!cmd || cmd === "setup") {
11
+ setup();
12
+ } else if (cmd === "status") {
13
+ status();
14
+ } else if (cmd === "proofs") {
15
+ proofs();
16
+ } else if (cmd === "uninstall") {
17
+ uninstall();
18
+ } else {
19
+ console.log(`
20
+ aimessage — Your AI asks before it acts.
21
+
22
+ Commands:
23
+ setup Install the Claude Code hook
24
+ status Check if hook is active
25
+ proofs Show recent proofs
26
+ uninstall Remove the hook
27
+ `);
28
+ }
29
+
30
+ function setup() {
31
+ console.log("\n AiMessage Setup\n");
32
+
33
+ // Check macOS
34
+ if (process.platform !== "darwin") {
35
+ console.log(" ✗ AiMessage requires macOS (for iMessage).\n");
36
+ process.exit(1);
37
+ }
38
+
39
+ // Check Messages app access
40
+ try {
41
+ execSync('osascript -e \'tell application "Messages" to name\'', { stdio: "pipe" });
42
+ console.log(" ✓ Messages app accessible");
43
+ } catch {
44
+ console.log(" ✗ Can't access Messages app. Open Messages and sign in to iMessage first.\n");
45
+ process.exit(1);
46
+ }
47
+
48
+ // Get phone number
49
+ const readline = require("readline");
50
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
51
+
52
+ rl.question(" Your phone number (for iMessage replies): ", (phone) => {
53
+ phone = phone.trim();
54
+ if (!phone) {
55
+ console.log(" ✗ Phone number required.\n");
56
+ process.exit(1);
57
+ }
58
+
59
+ // Save config
60
+ const configDir = path.join(os.homedir(), ".aimessage");
61
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
62
+
63
+ fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
64
+ phone,
65
+ createdAt: new Date().toISOString(),
66
+ }, null, 2));
67
+
68
+ console.log(" ✓ Phone saved");
69
+
70
+ // Create proof database
71
+ initDb(configDir);
72
+ console.log(" ✓ Proof database created");
73
+
74
+ // Install hook into Claude Code settings
75
+ installHook(configDir);
76
+ console.log(" ✓ Claude Code hook installed");
77
+
78
+ // Test iMessage
79
+ console.log("\n Sending test message...");
80
+ try {
81
+ sendMessage(phone, 'AiMessage is set up. Reply "ok" to confirm.');
82
+ console.log(" ✓ Check your phone for the test message");
83
+ } catch (e) {
84
+ console.log(" ⚠ Couldn't send test message. Make sure Messages is open and signed in.");
85
+ }
86
+
87
+ console.log("\n Done. Open Claude Code — every action will ask you first.\n");
88
+ rl.close();
89
+ });
90
+ }
91
+
92
+ function installHook(configDir) {
93
+ const hookScript = path.join(configDir, "hook.sh");
94
+ const nodeScript = path.join(configDir, "check.js");
95
+
96
+ // Write the check script (Node.js — handles iMessage + proofs)
97
+ fs.writeFileSync(nodeScript, fs.readFileSync(path.join(__dirname, "..", "lib", "check.js"), "utf8"));
98
+
99
+ // Write the shell hook
100
+ fs.writeFileSync(hookScript, `#!/bin/bash
101
+ node "${nodeScript}"
102
+ `, { mode: 0o755 });
103
+
104
+ // Update Claude Code settings.json
105
+ const claudeDir = path.join(os.homedir(), ".claude");
106
+ if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
107
+
108
+ const settingsPath = path.join(claudeDir, "settings.json");
109
+ let settings = {};
110
+ if (fs.existsSync(settingsPath)) {
111
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
112
+ }
113
+
114
+ if (!settings.hooks) settings.hooks = {};
115
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
116
+
117
+ // Remove existing aimessage hooks
118
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
119
+ h => !JSON.stringify(h).includes("aimessage")
120
+ );
121
+
122
+ // Add new hook
123
+ settings.hooks.PreToolUse.push({
124
+ matcher: "",
125
+ hooks: [{
126
+ type: "command",
127
+ command: hookScript,
128
+ }],
129
+ });
130
+
131
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
132
+ }
133
+
134
+ function initDb(configDir) {
135
+ const dbPath = path.join(configDir, "proofs.db");
136
+ try {
137
+ const Database = require("better-sqlite3");
138
+ const db = new Database(dbPath);
139
+ db.exec(`
140
+ CREATE TABLE IF NOT EXISTS proofs (
141
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
142
+ tool TEXT NOT NULL,
143
+ args TEXT,
144
+ decision TEXT NOT NULL,
145
+ timestamp TEXT NOT NULL,
146
+ prev_hash TEXT,
147
+ hash TEXT
148
+ )
149
+ `);
150
+ db.close();
151
+ } catch {
152
+ // If better-sqlite3 isn't available, we'll create on first use
153
+ fs.writeFileSync(path.join(configDir, "proofs.json"), "[]");
154
+ }
155
+ }
156
+
157
+ function sendMessage(phone, text) {
158
+ const escaped = text.replace(/"/g, '\\"');
159
+ execSync(`osascript -e '
160
+ tell application "Messages"
161
+ set targetBuddy to "${phone}"
162
+ set targetService to id of 1st account whose service type = iMessage
163
+ set theBuddy to participant targetBuddy of account id targetService
164
+ send "${escaped}" to theBuddy
165
+ end tell
166
+ '`, { stdio: "pipe" });
167
+ }
168
+
169
+ function status() {
170
+ const configDir = path.join(os.homedir(), ".aimessage");
171
+ const configPath = path.join(configDir, "config.json");
172
+
173
+ if (!fs.existsSync(configPath)) {
174
+ console.log("\n AiMessage is not set up. Run: aimessage setup\n");
175
+ return;
176
+ }
177
+
178
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
179
+ console.log(`\n AiMessage Status`);
180
+ console.log(` Phone: ${config.phone}`);
181
+ console.log(` Since: ${new Date(config.createdAt).toLocaleDateString()}`);
182
+
183
+ // Check hook
184
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
185
+ if (fs.existsSync(settingsPath)) {
186
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
187
+ const hasHook = JSON.stringify(settings.hooks || {}).includes("aimessage");
188
+ console.log(` Hook: ${hasHook ? "active" : "not found"}`);
189
+ }
190
+
191
+ console.log("");
192
+ }
193
+
194
+ function proofs() {
195
+ const configDir = path.join(os.homedir(), ".aimessage");
196
+
197
+ // Try JSON fallback
198
+ const jsonPath = path.join(configDir, "proofs.json");
199
+ if (fs.existsSync(jsonPath)) {
200
+ const proofs = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
201
+ if (proofs.length === 0) {
202
+ console.log("\n No proofs yet.\n");
203
+ return;
204
+ }
205
+ console.log(`\n Recent proofs (${proofs.length} total):\n`);
206
+ proofs.slice(-10).forEach(p => {
207
+ const icon = p.decision === "allow" ? "✓" : "✗";
208
+ console.log(` ${icon} ${p.tool} — ${p.decision} — ${new Date(p.timestamp).toLocaleString()}`);
209
+ });
210
+ console.log("");
211
+ return;
212
+ }
213
+
214
+ // Try SQLite
215
+ try {
216
+ const Database = require("better-sqlite3");
217
+ const db = new Database(path.join(configDir, "proofs.db"));
218
+ const rows = db.prepare("SELECT * FROM proofs ORDER BY id DESC LIMIT 10").all();
219
+ db.close();
220
+
221
+ if (rows.length === 0) {
222
+ console.log("\n No proofs yet.\n");
223
+ return;
224
+ }
225
+ console.log(`\n Recent proofs:\n`);
226
+ rows.reverse().forEach(r => {
227
+ const icon = r.decision === "allow" ? "✓" : "✗";
228
+ console.log(` ${icon} ${r.tool} — ${r.decision} — ${new Date(r.timestamp).toLocaleString()}`);
229
+ });
230
+ console.log("");
231
+ } catch {
232
+ console.log("\n No proofs found.\n");
233
+ }
234
+ }
235
+
236
+ function uninstall() {
237
+ // Remove hook from settings
238
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
239
+ if (fs.existsSync(settingsPath)) {
240
+ try {
241
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
242
+ if (settings.hooks?.PreToolUse) {
243
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
244
+ h => !JSON.stringify(h).includes("aimessage")
245
+ );
246
+ }
247
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
248
+ console.log("\n ✓ Hook removed from Claude Code");
249
+ } catch {}
250
+ }
251
+
252
+ console.log(" ✓ AiMessage uninstalled\n");
253
+ console.log(" Your proofs are still in ~/.aimessage/\n");
254
+ }
package/lib/check.js ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+
3
+ // AiMessage — PreToolUse hook for Claude Code
4
+ // Reads tool call from stdin, sends iMessage, waits for reply, allows or denies.
5
+
6
+ const { execSync } = require("child_process");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const os = require("os");
10
+ const crypto = require("crypto");
11
+
12
+ const CONFIG_DIR = path.join(os.homedir(), ".aimessage");
13
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
14
+ const PROOFS_PATH = path.join(CONFIG_DIR, "proofs.json");
15
+ const ALLOWED_PATH = path.join(CONFIG_DIR, "allowed.json");
16
+
17
+ // Read-only tools that don't need approval
18
+ const SKIP_TOOLS = new Set([
19
+ "Read", "Glob", "Grep", "WebSearch", "WebFetch",
20
+ "mcp__Claude_Preview__preview_screenshot",
21
+ "mcp__Claude_Preview__preview_snapshot",
22
+ "mcp__Claude_Preview__preview_inspect",
23
+ "mcp__Claude_Preview__preview_logs",
24
+ "mcp__Claude_Preview__preview_console_logs",
25
+ "mcp__Claude_Preview__preview_network",
26
+ "mcp__Claude_Preview__preview_list",
27
+ "TaskOutput",
28
+ ]);
29
+
30
+ async function main() {
31
+ // Read stdin (Claude Code sends JSON with tool info)
32
+ let input = "";
33
+ for await (const chunk of process.stdin) input += chunk;
34
+
35
+ let data;
36
+ try { data = JSON.parse(input); } catch { process.exit(0); } // Can't parse = allow
37
+
38
+ const tool = data.tool_name || data.tool || "unknown";
39
+ const args = data.tool_input || data.input || {};
40
+
41
+ // Skip read-only tools
42
+ if (SKIP_TOOLS.has(tool)) process.exit(0);
43
+
44
+ // Load config
45
+ if (!fs.existsSync(CONFIG_PATH)) {
46
+ process.stderr.write("AiMessage not set up. Run: aimessage setup\n");
47
+ process.exit(0); // Allow if not configured (don't block)
48
+ }
49
+
50
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
51
+ const phone = config.phone;
52
+
53
+ // Check if already allowed
54
+ const allowed = loadAllowed();
55
+ if (allowed.has(tool)) {
56
+ recordProof(tool, args, "allow", "previously approved");
57
+ process.exit(0);
58
+ }
59
+
60
+ // Build human-readable message
61
+ const summary = buildSummary(tool, args);
62
+ const msg = `🔒 ${summary}\n\nReply: YES (always) / ONCE / NO`;
63
+
64
+ // Send iMessage
65
+ try {
66
+ sendMessage(phone, msg);
67
+ } catch (e) {
68
+ process.stderr.write(`AiMessage: couldn't send iMessage: ${e.message}\n`);
69
+ process.exit(0); // Allow if can't message (don't block work)
70
+ }
71
+
72
+ // Wait for reply (poll Messages for up to 120 seconds)
73
+ const reply = await waitForReply(phone, 120);
74
+
75
+ if (!reply) {
76
+ // Timeout — deny
77
+ recordProof(tool, args, "deny", "timeout");
78
+ sendMessage(phone, "⏰ Timed out. Action denied.");
79
+ process.stderr.write("AiMessage: timed out waiting for reply. Denied.\n");
80
+ process.exit(2);
81
+ }
82
+
83
+ const answer = reply.trim().toUpperCase();
84
+
85
+ if (answer === "YES" || answer === "Y") {
86
+ // Always allow this tool
87
+ allowed.add(tool);
88
+ saveAllowed(allowed);
89
+ recordProof(tool, args, "allow", "always");
90
+ sendMessage(phone, "✅ Allowed (always).");
91
+ process.exit(0);
92
+ } else if (answer === "ONCE" || answer === "1") {
93
+ // Allow once
94
+ recordProof(tool, args, "allow", "once");
95
+ sendMessage(phone, "✅ Allowed (once).");
96
+ process.exit(0);
97
+ } else {
98
+ // Deny
99
+ recordProof(tool, args, "deny", "denied by user");
100
+ sendMessage(phone, "❌ Denied.");
101
+ process.stderr.write("AiMessage: denied by user.\n");
102
+ process.exit(2);
103
+ }
104
+ }
105
+
106
+ function buildSummary(tool, args) {
107
+ const name = tool.replace(/[_-]/g, " ");
108
+
109
+ if (args.file_path || args.path) return `${name}: ${args.file_path || args.path}`;
110
+ if (args.command) return `${name}: $ ${String(args.command).slice(0, 80)}`;
111
+ if (args.url) return `${name}: ${args.url}`;
112
+ if (args.query) return `${name}: "${String(args.query).slice(0, 60)}"`;
113
+ if (args.content && args.file_path) return `${name}: write to ${args.file_path}`;
114
+
115
+ return name;
116
+ }
117
+
118
+ function sendMessage(phone, text) {
119
+ const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
120
+ execSync(`osascript <<'SCPT'
121
+ tell application "Messages"
122
+ set targetService to id of 1st account whose service type = iMessage
123
+ set theBuddy to participant "${phone}" of account id targetService
124
+ send "${escaped}" to theBuddy
125
+ end tell
126
+ SCPT`, { stdio: "pipe", timeout: 10000 });
127
+ }
128
+
129
+ function waitForReply(phone, timeoutSeconds) {
130
+ return new Promise((resolve) => {
131
+ const start = Date.now();
132
+ const deadline = start + timeoutSeconds * 1000;
133
+
134
+ // Get current message count to know baseline
135
+ const baseline = getLatestMessage(phone);
136
+ const baselineText = baseline || "";
137
+ const baselineTime = Date.now();
138
+
139
+ const poll = setInterval(() => {
140
+ if (Date.now() > deadline) {
141
+ clearInterval(poll);
142
+ resolve(null);
143
+ return;
144
+ }
145
+
146
+ try {
147
+ const latest = getLatestMessage(phone);
148
+ // Check if it's a new message (different from baseline)
149
+ if (latest && latest !== baselineText) {
150
+ const upper = latest.trim().toUpperCase();
151
+ if (["YES", "Y", "NO", "N", "ONCE", "1"].includes(upper)) {
152
+ clearInterval(poll);
153
+ resolve(latest);
154
+ return;
155
+ }
156
+ }
157
+ } catch {}
158
+ }, 2000); // Poll every 2 seconds
159
+ });
160
+ }
161
+
162
+ function getLatestMessage(phone) {
163
+ try {
164
+ // Query Messages database directly for the most recent incoming message
165
+ const dbPath = path.join(os.homedir(), "Library", "Messages", "chat.db");
166
+ const result = execSync(`sqlite3 "${dbPath}" "
167
+ SELECT m.text FROM message m
168
+ JOIN handle h ON m.handle_id = h.ROWID
169
+ WHERE h.id LIKE '%${phone.replace(/[^0-9+]/g, "")}%'
170
+ AND m.is_from_me = 0
171
+ ORDER BY m.date DESC LIMIT 1;
172
+ "`, { stdio: "pipe", timeout: 5000 }).toString().trim();
173
+ return result || null;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ function loadAllowed() {
180
+ if (!fs.existsSync(ALLOWED_PATH)) return new Set();
181
+ try {
182
+ return new Set(JSON.parse(fs.readFileSync(ALLOWED_PATH, "utf8")));
183
+ } catch { return new Set(); }
184
+ }
185
+
186
+ function saveAllowed(set) {
187
+ fs.writeFileSync(ALLOWED_PATH, JSON.stringify([...set], null, 2));
188
+ }
189
+
190
+ function recordProof(tool, args, decision, reason) {
191
+ let proofs = [];
192
+ if (fs.existsSync(PROOFS_PATH)) {
193
+ try { proofs = JSON.parse(fs.readFileSync(PROOFS_PATH, "utf8")); } catch {}
194
+ }
195
+
196
+ const prevHash = proofs.length > 0 ? proofs[proofs.length - 1].hash : null;
197
+ const payload = JSON.stringify({ tool, decision, reason, timestamp: new Date().toISOString(), prevHash });
198
+ const hash = crypto.createHash("sha256").update(payload).digest("hex");
199
+
200
+ proofs.push({
201
+ tool,
202
+ args: typeof args === "object" ? JSON.stringify(args).slice(0, 500) : null,
203
+ decision,
204
+ reason,
205
+ timestamp: new Date().toISOString(),
206
+ prevHash,
207
+ hash,
208
+ });
209
+
210
+ fs.writeFileSync(PROOFS_PATH, JSON.stringify(proofs, null, 2));
211
+ }
212
+
213
+ main().catch(() => process.exit(0));
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "aimessage-app",
3
+ "version": "1.0.0",
4
+ "description": "Your AI asks before it acts. Approve or deny AI actions via iMessage.",
5
+ "bin": {
6
+ "aimessage": "./bin/aimessage.js"
7
+ },
8
+ "files": ["bin/", "lib/", "README.md"],
9
+ "keywords": ["ai", "imessage", "claude", "occ", "approval", "governance"],
10
+ "license": "MIT",
11
+ "author": "Mike Argento",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/mikeargento/occ"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "dependencies": {
20
+ "better-sqlite3": "^11.0.0"
21
+ }
22
+ }