baychat 0.30.0 → 0.31.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/permission.js +6 -0
- 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/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
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
|
*
|
|
@@ -33,34 +33,59 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.TRANSCRIPT_WAIT_MS = void 0;
|
|
36
|
+
exports.CONFIRM_WAIT_MS = exports.TRANSCRIPT_WAIT_MS = void 0;
|
|
37
|
+
exports.claudeConfigDir = claudeConfigDir;
|
|
38
|
+
exports.transcriptFileOf = transcriptFileOf;
|
|
37
39
|
exports.verifyDeniedCall = verifyDeniedCall;
|
|
38
40
|
exports.findToolUse = findToolUse;
|
|
39
41
|
// Proof that a denial really happened: the denied call must be in the session's own transcript.
|
|
40
42
|
//
|
|
41
43
|
// WHY. `baychat denied-hook` reads its event from stdin, and stdin is something anybody can
|
|
42
44
|
// write — including the agent, by running `echo '{…}' | baychat denied-hook` itself. A forged
|
|
43
|
-
// event would put a card on the owner's phone claiming a block that never occurred. So
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
45
|
+
// event would put a card on the owner's phone claiming a block that never occurred. So the call
|
|
46
|
+
// the event names (`tool_use_id`, `tool_name`, and the command or target) must appear as a tool
|
|
47
|
+
// call the MODEL made, in the transcript Claude Code keeps under `~/.claude/projects/` — a
|
|
48
|
+
// protected path the agent cannot write without a prompt. Before the card when it is already
|
|
49
|
+
// there; otherwise after the owner's yes, which is then taken back when it never appears.
|
|
47
50
|
//
|
|
48
51
|
// It is defence in depth, not the boundary: the owner's answer is. A card the agent somehow did
|
|
49
52
|
// cause still writes nothing unless the owner taps a yes.
|
|
50
53
|
//
|
|
51
|
-
// THE
|
|
52
|
-
//
|
|
54
|
+
// THE CALL IS NOT THERE YET WHILE THE HOOK RUNS. Measured on Claude Code 2.1.299 (2026-10-14, a
|
|
55
|
+
// file watcher on the transcript): the assistant line holding the denied `tool_use` — and the
|
|
56
|
+
// denial's result — reach the file only AFTER the PermissionDenied hook returns. The hook gave up
|
|
57
|
+
// at 15:32:46.742Z after waiting 45 s; the line landed at 15:32:47.029Z. Same for an MCP call. So
|
|
58
|
+
// waiting longer can never prove a denial up front. The hook now looks only briefly
|
|
59
|
+
// (TRANSCRIPT_WAIT_MS, for a Claude Code that does write first), and an absent call is proven the
|
|
60
|
+
// other way — the event came from the live Claude Code of that very session (live-session.ts) —
|
|
61
|
+
// and checked here AFTERWARDS, by `baychat permissions confirm` (confirm.ts, CONFIRM_WAIT_MS),
|
|
62
|
+
// which takes the rule back out when the call never shows up.
|
|
63
|
+
//
|
|
53
64
|
// For a subagent's call the hook still gets the MAIN transcript (with `agent_id`); its calls are
|
|
54
65
|
// in `<session>/subagents/agent-<id>.jsonl` beside it.
|
|
55
66
|
const fs = __importStar(require("fs"));
|
|
56
67
|
const os = __importStar(require("os"));
|
|
57
68
|
const path = __importStar(require("path"));
|
|
58
69
|
const permission_rule_1 = require("./permission-rule");
|
|
59
|
-
/**
|
|
60
|
-
|
|
70
|
+
/** The hook's own look at the transcript: brief, because Claude Code 2.1.299 writes the denied call
|
|
71
|
+
* only after the hook returns (above), so this succeeds only on a Claude Code that writes first. */
|
|
72
|
+
exports.TRANSCRIPT_WAIT_MS = 2_000;
|
|
73
|
+
/** How long the deferred confirmation (`baychat permissions confirm`) waits for the call to reach
|
|
74
|
+
* the transcript after the owner's yes. Measured: well under a second after the hook returns. */
|
|
75
|
+
exports.CONFIRM_WAIT_MS = 90_000;
|
|
61
76
|
const TRANSCRIPT_POLL_MS = 250;
|
|
62
77
|
/** Only the end of the file is read: the denied call is the newest thing in it. */
|
|
63
78
|
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
79
|
+
/** Claude Code's config directory: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
|
|
80
|
+
function claudeConfigDir(claudeDir) {
|
|
81
|
+
return claudeDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
82
|
+
}
|
|
83
|
+
/** The file a call is written to: the main transcript, or a subagent's own beside it. */
|
|
84
|
+
function transcriptFileOf(transcriptPath, agentId) {
|
|
85
|
+
return agentId
|
|
86
|
+
? path.join(transcriptPath.slice(0, -".jsonl".length), "subagents", `${agentId.startsWith("agent-") ? agentId : `agent-${agentId}`}.jsonl`)
|
|
87
|
+
: transcriptPath;
|
|
88
|
+
}
|
|
64
89
|
/** Did the model really make this call in this session? NEVER THROWS. */
|
|
65
90
|
async function verifyDeniedCall(ref, deps = {}) {
|
|
66
91
|
const now = deps.now ?? Date.now;
|
|
@@ -68,17 +93,15 @@ async function verifyDeniedCall(ref, deps = {}) {
|
|
|
68
93
|
const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
|
|
69
94
|
const readTail = deps.readTail ?? readFileTail;
|
|
70
95
|
try {
|
|
71
|
-
const projects = realpath(path.join(deps.claudeDir
|
|
72
|
-
const wanted = (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
|
|
96
|
+
const projects = realpath(path.join(claudeConfigDir(deps.claudeDir), "projects"));
|
|
97
|
+
const wanted = ref.call ?? (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
|
|
73
98
|
if (!ref.toolUseId || !wanted)
|
|
74
99
|
return { ok: false, why: "the event names no call that can be checked" };
|
|
75
100
|
if (!path.isAbsolute(ref.transcriptPath) || !ref.transcriptPath.endsWith(".jsonl")) {
|
|
76
101
|
return { ok: false, why: "the event carries no transcript" };
|
|
77
102
|
}
|
|
78
|
-
const file = ref.agentId
|
|
79
|
-
|
|
80
|
-
: ref.transcriptPath;
|
|
81
|
-
const giveUpAt = now() + exports.TRANSCRIPT_WAIT_MS;
|
|
103
|
+
const file = transcriptFileOf(ref.transcriptPath, ref.agentId);
|
|
104
|
+
const giveUpAt = now() + (deps.waitMs ?? exports.TRANSCRIPT_WAIT_MS);
|
|
82
105
|
for (;;) {
|
|
83
106
|
let real;
|
|
84
107
|
try {
|
|
@@ -99,7 +122,7 @@ async function verifyDeniedCall(ref, deps = {}) {
|
|
|
99
122
|
: { ok: false, why: "the transcript's call is not the one the event names" };
|
|
100
123
|
}
|
|
101
124
|
if (now() >= giveUpAt)
|
|
102
|
-
return { ok: false, why: "the call is not in the session's transcript" };
|
|
125
|
+
return { ok: false, why: "the call is not in the session's transcript", absent: true };
|
|
103
126
|
await sleep(TRANSCRIPT_POLL_MS);
|
|
104
127
|
}
|
|
105
128
|
}
|