baychat 0.30.0 → 0.32.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/.claude-plugin/plugin.json +1 -1
- package/README.md +36 -3
- package/dist/approval-wait.js +46 -33
- package/dist/approve-hook.js +35 -1
- package/dist/auto-update/describe.js +66 -0
- package/dist/auto-update/notice.js +40 -0
- package/dist/auto-update/policy.js +110 -0
- package/dist/auto-update/relay-glue.js +143 -0
- package/dist/auto-update/store.js +140 -0
- package/dist/auto-update/updater.js +324 -0
- package/dist/channel/feed.js +50 -1
- package/dist/channel/permission.js +6 -0
- package/dist/channel/server.js +1 -0
- package/dist/claude-onboarding.js +3 -1
- package/dist/cli-help.js +6 -1
- package/dist/denied/after-hook.js +8 -2
- package/dist/denied/confirm.js +131 -0
- package/dist/denied/grants.js +60 -2
- package/dist/denied/hook-log.js +75 -0
- package/dist/denied/live-session.js +154 -0
- package/dist/denied/notice.js +29 -0
- package/dist/denied/permission-rule.js +9 -6
- package/dist/denied/settings-rules.js +61 -0
- package/dist/denied/transcript.js +39 -16
- package/dist/denied-hook.js +132 -30
- package/dist/doctor-command.js +7 -1
- package/dist/doctor.js +28 -0
- package/dist/main.js +11 -2
- package/dist/permissions-command.js +18 -1
- package/dist/protocol-content.js +1 -1
- package/dist/refresh-clients.js +36 -0
- package/dist/relay/acp/runner.js +4 -0
- package/dist/relay/commands.js +19 -3
- package/dist/relay/daemon.js +37 -1
- package/dist/relay/launcher/loop.js +4 -1
- package/dist/relay/self-restart.js +149 -0
- package/dist/relay/terminal-pane.js +74 -2
- package/dist/self-upgrade.js +92 -27
- package/dist/start-command.js +15 -1
- package/dist/update-command.js +51 -31
- package/dist/waiting-cards.js +107 -0
- package/package.json +4 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.confirmGrant = confirmGrant;
|
|
37
|
+
exports.takeBackStaleUnconfirmed = takeBackStaleUnconfirmed;
|
|
38
|
+
// `baychat permissions confirm --grant <id>` — the deferred half of the denied-command card's proof.
|
|
39
|
+
//
|
|
40
|
+
// Claude Code 2.1.299 writes a denied call to the transcript only after the PermissionDenied hook
|
|
41
|
+
// returns (transcript.ts), so the hook accepts such a call on the live-session proof
|
|
42
|
+
// (live-session.ts) and records the owner's grant as UNCONFIRMED. Just before printing `retry` it
|
|
43
|
+
// starts this command detached (like `permissions sweep`), which polls the transcript for the
|
|
44
|
+
// original `tool_use_id` for up to CONFIRM_WAIT_MS:
|
|
45
|
+
//
|
|
46
|
+
// • found, and the same call → the grant is marked confirmed and stands;
|
|
47
|
+
// • not found, or another call under that id → the grant's rules are taken back out of the file
|
|
48
|
+
// they went into, and the owner gets an info card saying so.
|
|
49
|
+
//
|
|
50
|
+
// A confirmation that never finishes (the child killed, the computer asleep) is caught by the next
|
|
51
|
+
// run of either hook or of a sweep: an unconfirmed grant older than UNCONFIRMED_STALE_MS is taken
|
|
52
|
+
// back the same way (`takeBackStaleUnconfirmed`).
|
|
53
|
+
const os = __importStar(require("os"));
|
|
54
|
+
const approval_wait_1 = require("../approval-wait");
|
|
55
|
+
const config_1 = require("../config");
|
|
56
|
+
const grants_1 = require("./grants");
|
|
57
|
+
const notice_1 = require("./notice");
|
|
58
|
+
const transcript_1 = require("./transcript");
|
|
59
|
+
const LOG_LABEL = "baychat permissions confirm";
|
|
60
|
+
/** Confirm grant `id` against the transcript, or take it back. NEVER THROWS. */
|
|
61
|
+
async function confirmGrant(id, deps = {}) {
|
|
62
|
+
const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
63
|
+
try {
|
|
64
|
+
const grant = (0, grants_1.findGrant)(id);
|
|
65
|
+
if (!grant) {
|
|
66
|
+
log(`${LOG_LABEL}: grant ${id} is already gone; nothing to confirm.`);
|
|
67
|
+
return "missing";
|
|
68
|
+
}
|
|
69
|
+
const pending = grant.unconfirmed;
|
|
70
|
+
if (!pending) {
|
|
71
|
+
log(`${LOG_LABEL}: grant ${id} was proven up front; nothing to confirm.`);
|
|
72
|
+
return "not-pending";
|
|
73
|
+
}
|
|
74
|
+
const verify = deps.verify ?? ((ref) => (0, transcript_1.verifyDeniedCall)(ref, { waitMs: transcript_1.CONFIRM_WAIT_MS }));
|
|
75
|
+
const verdict = await verify({
|
|
76
|
+
transcriptPath: pending.transcriptPath,
|
|
77
|
+
agentId: pending.agentId,
|
|
78
|
+
toolUseId: pending.toolUseId,
|
|
79
|
+
toolName: pending.toolName,
|
|
80
|
+
call: grant.call,
|
|
81
|
+
});
|
|
82
|
+
if (verdict.ok) {
|
|
83
|
+
const still = await (0, grants_1.markConfirmed)(id);
|
|
84
|
+
log(`${LOG_LABEL}: grant ${id} confirmed — the call is in the session's transcript${still ? "" : " (its once-rule was already used and removed)"}.`);
|
|
85
|
+
return "confirmed";
|
|
86
|
+
}
|
|
87
|
+
const revoked = await (0, grants_1.revokeGrant)(id);
|
|
88
|
+
log(`${LOG_LABEL}: grant ${id} NOT confirmed (${verdict.why}); ${revoked ? "its rules were removed" : "it was already gone"}.`);
|
|
89
|
+
// A once-grant gone by now was used by the retried call: the owner still has to hear of it.
|
|
90
|
+
if (revoked || grant.kind === "once")
|
|
91
|
+
await notifyOwner([{ grant, why: verdict.why, used: !revoked }], deps, log);
|
|
92
|
+
return "revoked";
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
log(`${LOG_LABEL}: grant ${id} could not be confirmed (${(0, approval_wait_1.reasonOf)(err)}); a later hook run takes it back when it goes stale.`);
|
|
96
|
+
return "missing";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Take back every unconfirmed grant whose confirmation should have finished, and tell the owner.
|
|
100
|
+
* Returns how many. NEVER THROWS. */
|
|
101
|
+
async function takeBackStaleUnconfirmed(deps = {}) {
|
|
102
|
+
const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
103
|
+
try {
|
|
104
|
+
const doomed = await (0, grants_1.sweepStaleUnconfirmed)((deps.now ?? Date.now)());
|
|
105
|
+
for (const grant of doomed)
|
|
106
|
+
log(`${LOG_LABEL}: grant ${grant.id} was never confirmed in time; its rules were removed.`);
|
|
107
|
+
if (doomed.length)
|
|
108
|
+
await notifyOwner(doomed.map((grant) => ({ grant, why: "its confirmation never finished", used: false })), deps, log);
|
|
109
|
+
return doomed.length;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
log(`${LOG_LABEL}: stale unconfirmed grants could not be swept (${(0, approval_wait_1.reasonOf)(err)}).`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function notifyOwner(items, deps, log) {
|
|
117
|
+
const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
|
|
118
|
+
if (!device) {
|
|
119
|
+
log(`${LOG_LABEL}: not logged in to BayChat, so the owner could not be told.`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const noticeDeps = {
|
|
123
|
+
now: deps.now ?? Date.now,
|
|
124
|
+
sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
|
|
125
|
+
log,
|
|
126
|
+
};
|
|
127
|
+
const host = (deps.hostname ?? os.hostname)();
|
|
128
|
+
for (const { grant, why, used } of items) {
|
|
129
|
+
await (0, notice_1.postNotice)(device, grant.session, (0, notice_1.unconfirmedNotice)(grant, host, why, used), noticeDeps, LOG_LABEL);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/denied/grants.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.ONCE_TTL_MS = void 0;
|
|
36
|
+
exports.UNCONFIRMED_STALE_MS = exports.ONCE_TTL_MS = void 0;
|
|
37
37
|
exports.grantRules = grantRules;
|
|
38
38
|
exports.listGrants = listGrants;
|
|
39
39
|
exports.coveringGrant = coveringGrant;
|
|
@@ -42,6 +42,12 @@ exports.revokeGrant = revokeGrant;
|
|
|
42
42
|
exports.consumeOnceGrants = consumeOnceGrants;
|
|
43
43
|
exports.sweepExpiredGrants = sweepExpiredGrants;
|
|
44
44
|
exports.hasOnceGrants = hasOnceGrants;
|
|
45
|
+
exports.hasGrantsToSweep = hasGrantsToSweep;
|
|
46
|
+
exports.findGrant = findGrant;
|
|
47
|
+
exports.markConfirmed = markConfirmed;
|
|
48
|
+
exports.isStaleUnconfirmed = isStaleUnconfirmed;
|
|
49
|
+
exports.hasStaleUnconfirmed = hasStaleUnconfirmed;
|
|
50
|
+
exports.sweepStaleUnconfirmed = sweepStaleUnconfirmed;
|
|
45
51
|
// The permission rules BayChat itself wrote on this computer, and the one owner of every change to
|
|
46
52
|
// them (`~/.baychat/permission-grants.json`, 0600).
|
|
47
53
|
//
|
|
@@ -61,10 +67,15 @@ const fs = __importStar(require("fs"));
|
|
|
61
67
|
const path = __importStar(require("path"));
|
|
62
68
|
const config_1 = require("../config");
|
|
63
69
|
const permission_rule_1 = require("./permission-rule");
|
|
70
|
+
const transcript_1 = require("./transcript");
|
|
64
71
|
const settings_rules_1 = require("./settings-rules");
|
|
65
72
|
/** How long a once-rule may outlive its card when the retried call never reports back. The retry
|
|
66
73
|
* starts within seconds; this only bounds a rule left behind by a session that went away. */
|
|
67
74
|
exports.ONCE_TTL_MS = 5 * 60_000;
|
|
75
|
+
/** An unconfirmed grant older than this lost its confirmation (the child was killed, or never
|
|
76
|
+
* started) and is taken back by the next hook run or sweep. The confirmation's own wait
|
|
77
|
+
* (CONFIRM_WAIT_MS) plus room for its start-up. */
|
|
78
|
+
exports.UNCONFIRMED_STALE_MS = transcript_1.CONFIRM_WAIT_MS + 30_000;
|
|
68
79
|
const LOCK_WAIT_MS = 5_000;
|
|
69
80
|
const LOCK_POLL_MS = 50;
|
|
70
81
|
const STALE_LOCK_MS = 30_000;
|
|
@@ -85,6 +96,7 @@ async function grantRules(request, now = Date.now()) {
|
|
|
85
96
|
kind: request.kind,
|
|
86
97
|
rules: added,
|
|
87
98
|
settingsFile: request.settingsFile,
|
|
99
|
+
scope: request.scope,
|
|
88
100
|
call: request.call,
|
|
89
101
|
shown: request.shown,
|
|
90
102
|
claudeSessionId: request.claudeSessionId,
|
|
@@ -92,6 +104,7 @@ async function grantRules(request, now = Date.now()) {
|
|
|
92
104
|
addedAt: new Date(now).toISOString(),
|
|
93
105
|
...(request.kind === "once" ? { expiresAt: new Date(now + exports.ONCE_TTL_MS).toISOString() } : {}),
|
|
94
106
|
retries: 0,
|
|
107
|
+
...(request.unconfirmed ? { unconfirmed: { ...request.unconfirmed, since: new Date(now).toISOString() } } : {}),
|
|
95
108
|
};
|
|
96
109
|
try {
|
|
97
110
|
writeGrants([...readGrants(), grant]);
|
|
@@ -140,10 +153,55 @@ async function consumeOnceGrants(claudeSessionId, call) {
|
|
|
140
153
|
async function sweepExpiredGrants(now = Date.now()) {
|
|
141
154
|
return revokeWhere((g) => g.kind === "once" && isExpired(g, now));
|
|
142
155
|
}
|
|
143
|
-
/** Is there any once-grant at all?
|
|
156
|
+
/** Is there any once-grant at all? Read without the lock. */
|
|
144
157
|
function hasOnceGrants() {
|
|
145
158
|
return readGrants().some((g) => g.kind === "once");
|
|
146
159
|
}
|
|
160
|
+
/** Anything a sweep could act on — a once-grant, or one still unconfirmed? The after-hook's fast
|
|
161
|
+
* exit, read without the lock. */
|
|
162
|
+
function hasGrantsToSweep() {
|
|
163
|
+
return readGrants().some((g) => g.kind === "once" || g.unconfirmed !== undefined);
|
|
164
|
+
}
|
|
165
|
+
/** One grant by id, read without the lock. */
|
|
166
|
+
function findGrant(id) {
|
|
167
|
+
return readGrants().find((g) => g.id === id);
|
|
168
|
+
}
|
|
169
|
+
/** The call was found in the transcript: the grant stands. False: no such grant (any more). */
|
|
170
|
+
async function markConfirmed(id) {
|
|
171
|
+
return withGrantsLock(() => {
|
|
172
|
+
const grants = readGrants();
|
|
173
|
+
if (!grants.some((g) => g.id === id))
|
|
174
|
+
return false;
|
|
175
|
+
writeGrants(grants.map((g) => (g.id === id ? withoutUnconfirmed(g) : g)));
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** Is this grant still waiting for a confirmation that should have finished by `now`? */
|
|
180
|
+
function isStaleUnconfirmed(grant, now = Date.now()) {
|
|
181
|
+
if (!grant.unconfirmed)
|
|
182
|
+
return false;
|
|
183
|
+
const since = Date.parse(grant.unconfirmed.since);
|
|
184
|
+
// Unreadable: it can only be taken back early.
|
|
185
|
+
return !Number.isFinite(since) || since + exports.UNCONFIRMED_STALE_MS <= now;
|
|
186
|
+
}
|
|
187
|
+
/** Is any grant's confirmation overdue? Read without the lock (the after-hook's cheap check). */
|
|
188
|
+
function hasStaleUnconfirmed(now = Date.now()) {
|
|
189
|
+
return readGrants().some((g) => isStaleUnconfirmed(g, now));
|
|
190
|
+
}
|
|
191
|
+
/** Take back every grant whose confirmation never finished. Returns them, for the owner's notice. */
|
|
192
|
+
async function sweepStaleUnconfirmed(now = Date.now()) {
|
|
193
|
+
return withGrantsLock(() => {
|
|
194
|
+
let grants = readGrants();
|
|
195
|
+
const doomed = grants.filter((g) => isStaleUnconfirmed(g, now));
|
|
196
|
+
for (const grant of doomed)
|
|
197
|
+
grants = revokeLocked(grant, grants);
|
|
198
|
+
return doomed;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function withoutUnconfirmed(grant) {
|
|
202
|
+
const { unconfirmed: _done, ...rest } = grant;
|
|
203
|
+
return rest;
|
|
204
|
+
}
|
|
147
205
|
async function revokeWhere(match) {
|
|
148
206
|
return withGrantsLock(() => {
|
|
149
207
|
let grants = readGrants();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.deniedHookLogPath = deniedHookLogPath;
|
|
37
|
+
exports.appendHookLog = appendHookLog;
|
|
38
|
+
exports.logBoth = logBoth;
|
|
39
|
+
// `~/.baychat/denied-hook.log` — the denied-command card's own log.
|
|
40
|
+
//
|
|
41
|
+
// Claude Code ignores a PermissionDenied hook's stderr, so without a file a silent hook is
|
|
42
|
+
// undiagnosable: "no card came" could mean never called, not joined, unproven call, or a failed
|
|
43
|
+
// post. Shared by the hook and by the detached `baychat permissions confirm` it starts, so one
|
|
44
|
+
// file tells the whole story of a denial. One line per step, NEVER the tool input (it can hold
|
|
45
|
+
// secrets), kept small.
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const config_1 = require("../config");
|
|
49
|
+
const HOOK_LOG_MAX_BYTES = 256 * 1024;
|
|
50
|
+
function deniedHookLogPath() {
|
|
51
|
+
return path.join((0, config_1.configDir)(), "denied-hook.log");
|
|
52
|
+
}
|
|
53
|
+
/** Append one line, rotating at HOOK_LOG_MAX_BYTES. NEVER THROWS. */
|
|
54
|
+
function appendHookLog(line) {
|
|
55
|
+
try {
|
|
56
|
+
const file = deniedHookLogPath();
|
|
57
|
+
try {
|
|
58
|
+
if (fs.statSync(file).size > HOOK_LOG_MAX_BYTES)
|
|
59
|
+
fs.renameSync(file, `${file}.1`);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// No log yet: nothing to rotate.
|
|
63
|
+
}
|
|
64
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
65
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
process.stderr.write(`baychat denied-hook: could not write its log (${err instanceof Error ? err.message : String(err)}).\n`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** stderr and the log file: what every denied-command process logs through. */
|
|
72
|
+
function logBoth(line) {
|
|
73
|
+
process.stderr.write(`${line}\n`);
|
|
74
|
+
appendHookLog(line);
|
|
75
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.proveLiveSession = proveLiveSession;
|
|
37
|
+
exports.readClaudeSessionFile = readClaudeSessionFile;
|
|
38
|
+
// The second proof a denial is real, for the moment the transcript cannot give the first: the
|
|
39
|
+
// event came from the live Claude Code that runs the session it names.
|
|
40
|
+
//
|
|
41
|
+
// WHY THIS EXISTS. Claude Code 2.1.299 writes the denied `tool_use` to the transcript only after
|
|
42
|
+
// the PermissionDenied hook returns (measured; transcript.ts), so the hook can never find it
|
|
43
|
+
// while it waits. What the hook CAN check while it waits:
|
|
44
|
+
//
|
|
45
|
+
// (a) the event's `transcript_path` is a file in Claude Code's own `projects/` folder (followed
|
|
46
|
+
// through symlinks) named `<session_id>.jsonl` — the session's own transcript, not one the
|
|
47
|
+
// event made up; and
|
|
48
|
+
// (b) one of the hook's own ancestors (≤ MAX_CLAUDE_HOPS from its parent) is a LIVE Claude Code
|
|
49
|
+
// whose `~/.claude/sessions/<pid>.json` (Claude Code writes it for every running session)
|
|
50
|
+
// says `sessionId` = the event's `session_id`, and that file belongs to the process that has
|
|
51
|
+
// the pid now, not to an earlier one that died and left its pid to be reused.
|
|
52
|
+
//
|
|
53
|
+
// WHAT IT DOES NOT PROVE. The agent's own Bash tool is a child of the same Claude Code, so an
|
|
54
|
+
// `echo '{…}' | baychat denied-hook` it runs passes (b) as well. That is why a call accepted this
|
|
55
|
+
// way is CONFIRMED afterwards against the transcript (confirm.ts) and taken back when it never
|
|
56
|
+
// shows up, and why the boundary remains the owner's tap on the exact command the card shows.
|
|
57
|
+
//
|
|
58
|
+
// PID REUSE. The file carries `startedAt` (ms) and, on Linux, `procStart` = field 22 of
|
|
59
|
+
// `/proc/<pid>/stat` (start time in clock ticks). When both that and the live value exist they
|
|
60
|
+
// must be EQUAL. Otherwise `startedAt` must lie within a few seconds of the start `ps` reports.
|
|
61
|
+
const fs = __importStar(require("fs"));
|
|
62
|
+
const path = __importStar(require("path"));
|
|
63
|
+
const approve_hook_1 = require("../approve-hook");
|
|
64
|
+
const claude_link_1 = require("../channel/claude-link");
|
|
65
|
+
const transcript_1 = require("./transcript");
|
|
66
|
+
/** `ps -o etime` has whole seconds, and the clock is read twice: this much early is the same start. */
|
|
67
|
+
const STARTED_EARLY_SLACK_MS = 3_000;
|
|
68
|
+
/** Claude Code writes its session file once it has started — 4.4 s after the kernel's start time
|
|
69
|
+
* on this VPS (2026-10-14); a busy disk makes it slower. Later than this is not that process's. */
|
|
70
|
+
const STARTED_LATE_SLACK_MS = 30_000;
|
|
71
|
+
/** A session id or agent id is one path segment of safe characters, never a path. */
|
|
72
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
73
|
+
/** Did this event come from the live Claude Code of the session it names? NEVER THROWS. */
|
|
74
|
+
function proveLiveSession(ref, deps = {}) {
|
|
75
|
+
const claudeDir = (0, transcript_1.claudeConfigDir)(deps.claudeDir);
|
|
76
|
+
const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
|
|
77
|
+
const parentOf = deps.parentOf ?? approve_hook_1.psParentOf;
|
|
78
|
+
const isAlive = deps.isAlive ?? claude_link_1.pidAlive;
|
|
79
|
+
const readSessionFile = deps.readSessionFile ?? ((pid) => readClaudeSessionFile(claudeDir, pid));
|
|
80
|
+
const procStart = deps.procStart ?? approve_hook_1.procStartTicks;
|
|
81
|
+
const ageMs = deps.ageMs ?? approve_hook_1.psAgeMs;
|
|
82
|
+
const now = deps.now ?? Date.now;
|
|
83
|
+
try {
|
|
84
|
+
// (a) The session's own transcript, in Claude Code's own folder.
|
|
85
|
+
if (!SAFE_ID.test(ref.sessionId))
|
|
86
|
+
return { ok: false, why: "the event's session id is not one Claude Code writes" };
|
|
87
|
+
if (ref.agentId !== undefined && !SAFE_ID.test(ref.agentId))
|
|
88
|
+
return { ok: false, why: "the event's agent id is not one Claude Code writes" };
|
|
89
|
+
const expected = `${ref.sessionId}.jsonl`;
|
|
90
|
+
if (!path.isAbsolute(ref.transcriptPath) || path.basename(ref.transcriptPath) !== expected) {
|
|
91
|
+
return { ok: false, why: `the event's transcript is not named ${expected}` };
|
|
92
|
+
}
|
|
93
|
+
const projects = realpath(path.join(claudeDir, "projects"));
|
|
94
|
+
let real;
|
|
95
|
+
try {
|
|
96
|
+
real = realpath(ref.transcriptPath);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Not written yet (a session's very first turn): its folder must still be Claude Code's.
|
|
100
|
+
real = path.join(realpath(path.dirname(ref.transcriptPath)), expected);
|
|
101
|
+
}
|
|
102
|
+
if (!real.startsWith(projects + path.sep) || path.basename(real) !== expected) {
|
|
103
|
+
return { ok: false, why: "the event's transcript is not in Claude Code's own folder" };
|
|
104
|
+
}
|
|
105
|
+
// (b) The nearest Claude Code above this process runs that session.
|
|
106
|
+
let pid = deps.ppid ?? process.ppid;
|
|
107
|
+
for (let hop = 0; hop < approve_hook_1.MAX_CLAUDE_HOPS && pid !== undefined && pid > 1; hop++) {
|
|
108
|
+
const file = readSessionFile(pid);
|
|
109
|
+
if (file && isAlive(pid)) {
|
|
110
|
+
// The nearest one decides: a hook runs under exactly one Claude Code.
|
|
111
|
+
if (file.sessionId !== ref.sessionId) {
|
|
112
|
+
return { ok: false, why: `the Claude Code this hook runs under (pid ${pid}) is another session` };
|
|
113
|
+
}
|
|
114
|
+
if (file.pid !== undefined && file.pid !== pid) {
|
|
115
|
+
return { ok: false, why: `the session file of pid ${pid} names another pid` };
|
|
116
|
+
}
|
|
117
|
+
const started = startMatches(pid, file, procStart, ageMs, now);
|
|
118
|
+
return started === true ? { ok: true, pid } : { ok: false, why: started };
|
|
119
|
+
}
|
|
120
|
+
pid = parentOf(pid);
|
|
121
|
+
}
|
|
122
|
+
return { ok: false, why: `no live Claude Code of this session among the hook's ${approve_hook_1.MAX_CLAUDE_HOPS} nearest ancestors` };
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
return { ok: false, why: `the session could not be checked (${err instanceof Error ? err.message : String(err)})` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** True when `file` was written by the process that has `pid` now; otherwise why not. */
|
|
129
|
+
function startMatches(pid, file, procStart, ageMs, now) {
|
|
130
|
+
const recorded = typeof file.procStart === "string" || typeof file.procStart === "number" ? String(file.procStart) : undefined;
|
|
131
|
+
const live = recorded === undefined ? undefined : procStart(pid);
|
|
132
|
+
if (recorded !== undefined && live !== undefined) {
|
|
133
|
+
return recorded === live ? true : `pid ${pid} is a newer process than its session file (start ${live}, file ${recorded})`;
|
|
134
|
+
}
|
|
135
|
+
const startedAt = typeof file.startedAt === "number" ? file.startedAt : Number.NaN;
|
|
136
|
+
const age = ageMs(pid);
|
|
137
|
+
if (!Number.isFinite(startedAt) || age === undefined)
|
|
138
|
+
return `when pid ${pid} started cannot be told`;
|
|
139
|
+
const processStart = now() - age;
|
|
140
|
+
if (startedAt < processStart - STARTED_EARLY_SLACK_MS || startedAt > processStart + STARTED_LATE_SLACK_MS) {
|
|
141
|
+
return `pid ${pid}'s session file was written by another process (started ${new Date(startedAt).toISOString()}, the process ${new Date(processStart).toISOString()})`;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
/** `<claudeDir>/sessions/<pid>.json`, or undefined when absent or not a JSON object. */
|
|
146
|
+
function readClaudeSessionFile(claudeDir, pid) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(claudeDir, "sessions", `${pid}.json`), "utf8"));
|
|
149
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NOTICE_OPTIONS = void 0;
|
|
4
|
+
exports.postNotice = postNotice;
|
|
5
|
+
exports.unconfirmedNotice = unconfirmedNotice;
|
|
6
|
+
// A card that only informs: posted to the owner's private chat with the session, never waited on.
|
|
7
|
+
// Shared by the hook and by the processes that take a grant back after it (confirm.ts).
|
|
8
|
+
const approval_wait_1 = require("../approval-wait");
|
|
9
|
+
/** The single button of a card that only informs: nothing can be allowed from it. */
|
|
10
|
+
exports.NOTICE_OPTIONS = ["OK"];
|
|
11
|
+
/** Post `question` as an info card. NEVER THROWS (askOwnerAndWait does not); its deadline is now. */
|
|
12
|
+
async function postNotice(device, session, question, deps, label) {
|
|
13
|
+
const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: exports.NOTICE_OPTIONS }, deps.now(), deps, label);
|
|
14
|
+
if (outcome.kind === "failed")
|
|
15
|
+
deps.log(`${label}: the notice could not be posted (${outcome.reason}).`);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The owner's notice when BayChat took back a grant it could not confirm. `used`: a once-grant
|
|
19
|
+
* whose rule the retried call had already used (and the after-hook removed) by then.
|
|
20
|
+
*/
|
|
21
|
+
function unconfirmedNotice(grant, host, why, used) {
|
|
22
|
+
const who = grant.session ? `session "${grant.session}"` : "a Claude Code session";
|
|
23
|
+
return (`BayChat removed the rule you allowed for ${who} on ${host}:\n\n${grant.shown}\n\n` +
|
|
24
|
+
`It could not confirm that the block came from that session — Claude Code's auto mode never recorded ` +
|
|
25
|
+
`this call in the session's transcript (${why}).` +
|
|
26
|
+
(used ? " The rule had already been used once." : "") +
|
|
27
|
+
"\n\nIf you did not expect this card, check what is running on that computer. If you still want the call " +
|
|
28
|
+
"allowed, approve it at the computer (/permissions → Recently denied).");
|
|
29
|
+
}
|
|
@@ -49,7 +49,7 @@ function planDeniedCall(toolName, toolInput) {
|
|
|
49
49
|
shown: `${toolName} ${clipJson(input)}`,
|
|
50
50
|
onceRules: [toolName],
|
|
51
51
|
alwaysAllowed: false,
|
|
52
|
-
limitation: "
|
|
52
|
+
limitation: "\"Always\" isn't offered: a rule for an MCP tool would allow every use of it, so BayChat only allows it once.",
|
|
53
53
|
call: { tool: toolName, subject: toolName },
|
|
54
54
|
};
|
|
55
55
|
}
|
|
@@ -88,16 +88,19 @@ function planBash(command) {
|
|
|
88
88
|
shown,
|
|
89
89
|
onceRules: [...new Set(parts.map((p) => `Bash(${p})`))],
|
|
90
90
|
alwaysAllowed: false,
|
|
91
|
-
|
|
91
|
+
// One plain line on WHY, because the usual cause is the agent's own habit (`cd … && x | tail`)
|
|
92
|
+
// and the fix is for the session to run the command by itself.
|
|
93
|
+
limitation: `"Always" isn't offered: this chains ${parts.length} commands, and a saved rule only ever covers one plain ` +
|
|
94
|
+
`command. Run it on its own (no cd … &&, no | tail) to be offered "Always".`,
|
|
92
95
|
call,
|
|
93
96
|
};
|
|
94
97
|
}
|
|
95
98
|
const refusal = redacted
|
|
96
|
-
? "
|
|
99
|
+
? "\"Always\" isn't offered: it carries what looks like a secret, and a saved rule would keep it in a settings file."
|
|
97
100
|
: command.includes("$")
|
|
98
|
-
? "
|
|
101
|
+
? "\"Always\" isn't offered: it expands a variable, so the same text could run something different later."
|
|
99
102
|
: /[<>]/.test(command)
|
|
100
|
-
? "
|
|
103
|
+
? "\"Always\" isn't offered: it redirects to or from a file."
|
|
101
104
|
: "";
|
|
102
105
|
return { shown, onceRules: [`Bash(${command})`], alwaysAllowed: refusal === "", limitation: refusal, call };
|
|
103
106
|
}
|
|
@@ -116,7 +119,7 @@ function planWebFetch(url) {
|
|
|
116
119
|
shown,
|
|
117
120
|
onceRules: [`WebFetch(domain:${host})`],
|
|
118
121
|
alwaysAllowed: false,
|
|
119
|
-
limitation: `
|
|
122
|
+
limitation: `"Always" isn't offered: a WebFetch rule covers the whole of ${host}, so BayChat only allows it once.`,
|
|
120
123
|
call: { tool: "WebFetch", subject: host },
|
|
121
124
|
};
|
|
122
125
|
}
|
|
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.addAllowRules = addAllowRules;
|
|
37
37
|
exports.removeAllowRules = removeAllowRules;
|
|
38
|
+
exports.chooseSettingsFile = chooseSettingsFile;
|
|
39
|
+
exports.settingsFileWritable = settingsFileWritable;
|
|
38
40
|
exports.localSettingsFile = localSettingsFile;
|
|
39
41
|
// Adding and removing exact `permissions.allow` entries in ONE Claude Code settings file — and
|
|
40
42
|
// touching nothing else in it.
|
|
@@ -118,6 +120,65 @@ function writeAllow(edit, allow, io) {
|
|
|
118
120
|
// A new file is private: a once-rule can carry a command's own arguments.
|
|
119
121
|
io.write(edit.target, `${JSON.stringify(next, null, indent)}\n`, io.mode(edit.target) ?? 0o600);
|
|
120
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* The settings file a rule for a session started in `projectDir` goes into.
|
|
125
|
+
*
|
|
126
|
+
* The project's local file when this user can write it. Otherwise — a repository owned by another
|
|
127
|
+
* user, the first real run's case (a session as `karmen` in `/var/www/baychat`, owned by `manuel`) —
|
|
128
|
+
* the user's own `~/.claude/settings.json`, which Claude Code also reloads live, and the card says
|
|
129
|
+
* so (it then applies to every project). When neither can be written there is nothing to offer, and
|
|
130
|
+
* the card must not draw an Allow button that can only fail.
|
|
131
|
+
*/
|
|
132
|
+
function chooseSettingsFile(projectDir, deps = {}) {
|
|
133
|
+
const writable = deps.writable ?? settingsFileWritable;
|
|
134
|
+
const project = (deps.localFile ?? localSettingsFile)(projectDir);
|
|
135
|
+
if (writable(project))
|
|
136
|
+
return { scope: "project", file: project };
|
|
137
|
+
const user = (deps.userFile ?? claude_settings_1.claudeSettingsPath)();
|
|
138
|
+
if (writable(user))
|
|
139
|
+
return { scope: "user", file: user, why: `${project} isn't writable for you` };
|
|
140
|
+
return { scope: "none", why: `neither ${project} nor ${user} is writable for you` };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Could `addAllowRules` write `file` as this user? The write is a temp file beside the real file
|
|
144
|
+
* and a rename, so what has to be writable is its FOLDER (or the nearest folder that exists, which
|
|
145
|
+
* `mkdir` would create it in); an existing file must also be readable. A folder that cannot be
|
|
146
|
+
* looked into is not writable — stat's error, not a missing folder (RECURRING_MISTAKES §26).
|
|
147
|
+
* NEVER THROWS: any doubt is "no".
|
|
148
|
+
*/
|
|
149
|
+
function settingsFileWritable(file, io = claude_settings_1.nodeSettingsIo) {
|
|
150
|
+
try {
|
|
151
|
+
const target = io.realpath(file);
|
|
152
|
+
try {
|
|
153
|
+
fs.accessSync(target, fs.constants.R_OK);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err.code !== "ENOENT")
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
let dir = path.dirname(target);
|
|
160
|
+
for (;;) {
|
|
161
|
+
try {
|
|
162
|
+
if (!fs.statSync(dir).isDirectory())
|
|
163
|
+
return false;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (err.code !== "ENOENT")
|
|
168
|
+
return false;
|
|
169
|
+
const up = path.dirname(dir);
|
|
170
|
+
if (up === dir)
|
|
171
|
+
return false;
|
|
172
|
+
dir = up;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
121
182
|
/**
|
|
122
183
|
* The `.claude/settings.local.json` Claude Code reads for a session started in `projectDir`.
|
|
123
184
|
*
|