baychat 0.29.9 → 0.30.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 +7 -7
- package/dist/approval-wait.js +130 -0
- package/dist/approve-hook.js +33 -132
- package/dist/cli-help.js +24 -2
- package/dist/denied/after-hook.js +40 -0
- package/dist/denied/grants.js +234 -0
- package/dist/denied/opt-in.js +68 -0
- package/dist/denied/permission-rule.js +183 -0
- package/dist/denied/settings-rules.js +160 -0
- package/dist/denied/transcript.js +150 -0
- package/dist/denied-hook.js +329 -0
- package/dist/help-topics.js +12 -4
- package/dist/hook-stdin.js +35 -0
- package/dist/index.js +13 -1
- package/dist/main.js +9 -0
- package/dist/permissions-command.js +87 -0
- package/dist/protocol-content.js +1 -1
- package/dist/relay/launcher/approval-settings.js +56 -2
- package/dist/runtimes.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,234 @@
|
|
|
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.ONCE_TTL_MS = void 0;
|
|
37
|
+
exports.grantRules = grantRules;
|
|
38
|
+
exports.listGrants = listGrants;
|
|
39
|
+
exports.coveringGrant = coveringGrant;
|
|
40
|
+
exports.noteRetry = noteRetry;
|
|
41
|
+
exports.revokeGrant = revokeGrant;
|
|
42
|
+
exports.consumeOnceGrants = consumeOnceGrants;
|
|
43
|
+
exports.sweepExpiredGrants = sweepExpiredGrants;
|
|
44
|
+
exports.hasOnceGrants = hasOnceGrants;
|
|
45
|
+
// The permission rules BayChat itself wrote on this computer, and the one owner of every change to
|
|
46
|
+
// them (`~/.baychat/permission-grants.json`, 0600).
|
|
47
|
+
//
|
|
48
|
+
// A GRANT is what one "Allow once" or "Always allow this exact command" answer did: the exact rules
|
|
49
|
+
// it added to which settings file, and the call they were for. Recording it is what lets BayChat
|
|
50
|
+
// take a once-rule back out after the retried call (or its TTL), list what it keeps
|
|
51
|
+
// (`baychat permissions list`) and remove one again (`baychat permissions remove`) — without ever
|
|
52
|
+
// touching a rule the person wrote themselves: a rule that was already in the file is not added,
|
|
53
|
+
// so it is not recorded, so it is never removed.
|
|
54
|
+
//
|
|
55
|
+
// ONE LOCK FOR ALL OF IT. The hook, the after-hook, the TTL sweeper and the command can run at the
|
|
56
|
+
// same time (two denials in one turn, a sweep while a retry lands), and each is a read-modify-write
|
|
57
|
+
// of two files. Every operation below runs under `permission-grants.lock` (an O_EXCL file, broken
|
|
58
|
+
// when older than STALE_LOCK_MS), so they queue instead of losing each other's writes.
|
|
59
|
+
const crypto = __importStar(require("crypto"));
|
|
60
|
+
const fs = __importStar(require("fs"));
|
|
61
|
+
const path = __importStar(require("path"));
|
|
62
|
+
const config_1 = require("../config");
|
|
63
|
+
const permission_rule_1 = require("./permission-rule");
|
|
64
|
+
const settings_rules_1 = require("./settings-rules");
|
|
65
|
+
/** How long a once-rule may outlive its card when the retried call never reports back. The retry
|
|
66
|
+
* starts within seconds; this only bounds a rule left behind by a session that went away. */
|
|
67
|
+
exports.ONCE_TTL_MS = 5 * 60_000;
|
|
68
|
+
const LOCK_WAIT_MS = 5_000;
|
|
69
|
+
const LOCK_POLL_MS = 50;
|
|
70
|
+
const STALE_LOCK_MS = 30_000;
|
|
71
|
+
const grantsFile = () => path.join((0, config_1.configDir)(), "permission-grants.json");
|
|
72
|
+
const lockFile = () => path.join((0, config_1.configDir)(), "permission-grants.lock");
|
|
73
|
+
/**
|
|
74
|
+
* Write the rules into the settings file and record the grant. All or nothing: when the record
|
|
75
|
+
* cannot be written the rules are taken back out, and when the rules cannot be written nothing is
|
|
76
|
+
* recorded.
|
|
77
|
+
*
|
|
78
|
+
* @throws with nothing changed (as far as the filesystem allows) when either write fails.
|
|
79
|
+
*/
|
|
80
|
+
async function grantRules(request, now = Date.now()) {
|
|
81
|
+
return withGrantsLock(() => {
|
|
82
|
+
const added = (0, settings_rules_1.addAllowRules)(request.settingsFile, request.rules);
|
|
83
|
+
const grant = {
|
|
84
|
+
id: crypto.randomBytes(4).toString("hex"),
|
|
85
|
+
kind: request.kind,
|
|
86
|
+
rules: added,
|
|
87
|
+
settingsFile: request.settingsFile,
|
|
88
|
+
call: request.call,
|
|
89
|
+
shown: request.shown,
|
|
90
|
+
claudeSessionId: request.claudeSessionId,
|
|
91
|
+
...(request.session ? { session: request.session } : {}),
|
|
92
|
+
addedAt: new Date(now).toISOString(),
|
|
93
|
+
...(request.kind === "once" ? { expiresAt: new Date(now + exports.ONCE_TTL_MS).toISOString() } : {}),
|
|
94
|
+
retries: 0,
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
writeGrants([...readGrants(), grant]);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
(0, settings_rules_1.removeAllowRules)(request.settingsFile, added);
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
return grant;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Every recorded grant, once-grants included. */
|
|
107
|
+
function listGrants() {
|
|
108
|
+
return readGrants();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The live grant that already covers this call, if any: an always-grant from any session, or a
|
|
112
|
+
* once-grant of this Claude Code session that has not expired.
|
|
113
|
+
*/
|
|
114
|
+
function coveringGrant(claudeSessionId, call, now = Date.now()) {
|
|
115
|
+
return readGrants().find((g) => (0, permission_rule_1.sameCall)(g.call, call) &&
|
|
116
|
+
(g.kind === "always" || (g.claudeSessionId === claudeSessionId && !isExpired(g, now))));
|
|
117
|
+
}
|
|
118
|
+
/** Count one silent retry against a grant. */
|
|
119
|
+
async function noteRetry(id) {
|
|
120
|
+
await withGrantsLock(() => {
|
|
121
|
+
writeGrants(readGrants().map((g) => (g.id === id ? { ...g, retries: g.retries + 1 } : g)));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Take a grant back: its rules out of the settings file, then its record. False: no such grant. */
|
|
125
|
+
async function revokeGrant(id) {
|
|
126
|
+
return withGrantsLock(() => {
|
|
127
|
+
const grants = readGrants();
|
|
128
|
+
const grant = grants.find((g) => g.id === id);
|
|
129
|
+
if (!grant)
|
|
130
|
+
return false;
|
|
131
|
+
revokeLocked(grant, grants);
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** After a call finished: take back the once-grants of this session that were for it. */
|
|
136
|
+
async function consumeOnceGrants(claudeSessionId, call) {
|
|
137
|
+
return revokeWhere((g) => g.kind === "once" && g.claudeSessionId === claudeSessionId && (0, permission_rule_1.sameCall)(g.call, call));
|
|
138
|
+
}
|
|
139
|
+
/** Take back every once-grant whose TTL has passed. */
|
|
140
|
+
async function sweepExpiredGrants(now = Date.now()) {
|
|
141
|
+
return revokeWhere((g) => g.kind === "once" && isExpired(g, now));
|
|
142
|
+
}
|
|
143
|
+
/** Is there any once-grant at all? The after-hook's fast exit, read without the lock. */
|
|
144
|
+
function hasOnceGrants() {
|
|
145
|
+
return readGrants().some((g) => g.kind === "once");
|
|
146
|
+
}
|
|
147
|
+
async function revokeWhere(match) {
|
|
148
|
+
return withGrantsLock(() => {
|
|
149
|
+
let grants = readGrants();
|
|
150
|
+
const doomed = grants.filter(match);
|
|
151
|
+
for (const grant of doomed)
|
|
152
|
+
grants = revokeLocked(grant, grants);
|
|
153
|
+
return doomed.length;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** Remove `grant`'s rules — except any another remaining grant also holds — then its record.
|
|
157
|
+
* Returns the remaining grants. Caller holds the lock. */
|
|
158
|
+
function revokeLocked(grant, grants) {
|
|
159
|
+
const rest = grants.filter((g) => g.id !== grant.id);
|
|
160
|
+
const stillHeld = new Set(rest.filter((g) => g.settingsFile === grant.settingsFile).flatMap((g) => g.rules));
|
|
161
|
+
(0, settings_rules_1.removeAllowRules)(grant.settingsFile, grant.rules.filter((r) => !stillHeld.has(r)));
|
|
162
|
+
writeGrants(rest);
|
|
163
|
+
return rest;
|
|
164
|
+
}
|
|
165
|
+
function isExpired(grant, now) {
|
|
166
|
+
const at = grant.expiresAt ? Date.parse(grant.expiresAt) : Number.NaN;
|
|
167
|
+
// A once-grant with no readable expiry is treated as expired: it can only be taken back early.
|
|
168
|
+
return grant.kind === "once" && (!Number.isFinite(at) || at <= now);
|
|
169
|
+
}
|
|
170
|
+
// ─── The file ────────────────────────────────────────────────────────────────
|
|
171
|
+
function readGrants() {
|
|
172
|
+
let text;
|
|
173
|
+
try {
|
|
174
|
+
text = fs.readFileSync(grantsFile(), "utf8");
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (err.code === "ENOENT")
|
|
178
|
+
return [];
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
const parsed = JSON.parse(text);
|
|
182
|
+
const grants = parsed?.grants;
|
|
183
|
+
if (!Array.isArray(grants))
|
|
184
|
+
throw new Error(`${grantsFile()} is not a BayChat grants file.`);
|
|
185
|
+
return grants;
|
|
186
|
+
}
|
|
187
|
+
function writeGrants(grants) {
|
|
188
|
+
const file = grantsFile();
|
|
189
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
190
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
191
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ version: 1, grants }, null, 2)}\n`, { mode: 0o600 });
|
|
192
|
+
fs.renameSync(tmp, file);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Run `fn` holding the grants lock. The lock is an exclusive-create file; one older than
|
|
196
|
+
* STALE_LOCK_MS belonged to a process that died holding it and is broken. Gives up (throws) after
|
|
197
|
+
* LOCK_WAIT_MS rather than waiting forever — every caller fails closed on a throw.
|
|
198
|
+
*/
|
|
199
|
+
async function withGrantsLock(fn) {
|
|
200
|
+
const file = lockFile();
|
|
201
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
202
|
+
const giveUpAt = Date.now() + LOCK_WAIT_MS;
|
|
203
|
+
for (;;) {
|
|
204
|
+
try {
|
|
205
|
+
fs.closeSync(fs.openSync(file, "wx", 0o600));
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (err.code !== "EEXIST")
|
|
210
|
+
throw err;
|
|
211
|
+
if (lockAge(file) > STALE_LOCK_MS) {
|
|
212
|
+
fs.rmSync(file, { force: true });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (Date.now() >= giveUpAt)
|
|
216
|
+
throw new Error(`Another BayChat process holds ${file}; try again.`);
|
|
217
|
+
await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
return fn();
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
fs.rmSync(file, { force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function lockAge(file) {
|
|
228
|
+
try {
|
|
229
|
+
return Date.now() - fs.statSync(file).mtimeMs;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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.deniedCardEnabled = deniedCardEnabled;
|
|
37
|
+
exports.enableDeniedCard = enableDeniedCard;
|
|
38
|
+
exports.disableDeniedCard = disableDeniedCard;
|
|
39
|
+
// Whether this computer's owner turned the denied-command card on (`baychat permissions on`).
|
|
40
|
+
//
|
|
41
|
+
// OFF UNTIL SOMEBODY ASKS FOR IT, like approve-hook was: the card lets a phone add a permission
|
|
42
|
+
// rule on this computer, so it exists only after a deliberate act. The switch is a file in
|
|
43
|
+
// `~/.baychat`, read when BayChat's launcher writes a session's `--settings` file
|
|
44
|
+
// (relay/launcher/approval-settings.ts), so it takes effect for sessions started after it changes.
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const config_1 = require("../config");
|
|
48
|
+
const optInFile = () => path.join((0, config_1.configDir)(), "denied-card.json");
|
|
49
|
+
/** True only when the switch file says so. Any doubt — missing, unreadable, malformed — is off. */
|
|
50
|
+
function deniedCardEnabled() {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(fs.readFileSync(optInFile(), "utf8"));
|
|
53
|
+
return parsed?.enabled === true;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err.code !== "ENOENT") {
|
|
57
|
+
process.stderr.write(`baychat: ${optInFile()} could not be read, so the denied-command card is off.\n`);
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function enableDeniedCard(now = new Date()) {
|
|
63
|
+
fs.mkdirSync((0, config_1.configDir)(), { recursive: true, mode: 0o700 });
|
|
64
|
+
fs.writeFileSync(optInFile(), `${JSON.stringify({ enabled: true, since: now.toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
65
|
+
}
|
|
66
|
+
function disableDeniedCard() {
|
|
67
|
+
fs.rmSync(optInFile(), { force: true });
|
|
68
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What BayChat may write into Claude Code's settings for ONE call that auto mode denied — and,
|
|
3
|
+
// just as important, what it refuses to write. Pure: no files, no network.
|
|
4
|
+
//
|
|
5
|
+
// THE RULE WE WRITE IS THE CALL, AND NOTHING WIDER. Claude Code resolves a matching
|
|
6
|
+
// `permissions.allow` rule before its auto-mode classifier (permission-modes docs, "How the
|
|
7
|
+
// classifier evaluates actions", step 1), so a rule is a real grant: whatever it matches runs
|
|
8
|
+
// without review. Its syntax is a pattern language — `Bash(<text>)` matches the whole command
|
|
9
|
+
// text with `*` as a wildcard, and a rule must match each subcommand of a chain on its own
|
|
10
|
+
// (permissions docs, "Compound commands"). So:
|
|
11
|
+
//
|
|
12
|
+
// • a command containing `*` is never turned into a rule: the star would be read as a wildcard
|
|
13
|
+
// and the rule would allow more than the person saw;
|
|
14
|
+
// • a command that runs another command inside it (backticks, `$( )`, brackets, a newline) has
|
|
15
|
+
// no exact rule at all — nothing is offered;
|
|
16
|
+
// • a chain (`a && b`, `a | b`, `a; b`) can only be allowed ONCE, as one rule per subcommand,
|
|
17
|
+
// and only when it holds no quotes, because splitting quoted shell text by hand is how a
|
|
18
|
+
// parser and a person end up disagreeing about what runs;
|
|
19
|
+
// • "Always" is offered only for ONE plain command with no variable expansion, no redirection
|
|
20
|
+
// and nothing that looks like a secret (a saved rule would keep that secret in a file).
|
|
21
|
+
//
|
|
22
|
+
// The card text is built from `shown`, which masks obvious secrets; the rule is built from the
|
|
23
|
+
// real text, because a rule that differs from the call matches nothing.
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.MAX_RULE_COMMAND_LENGTH = void 0;
|
|
26
|
+
exports.planDeniedCall = planDeniedCall;
|
|
27
|
+
exports.callKeyOf = callKeyOf;
|
|
28
|
+
exports.sameCall = sameCall;
|
|
29
|
+
exports.redactSecrets = redactSecrets;
|
|
30
|
+
/** Longer than this is not a command a person can check on a phone, nor one a rule should hold. */
|
|
31
|
+
exports.MAX_RULE_COMMAND_LENGTH = 1000;
|
|
32
|
+
/** Claude Code itself saves at most five rules for one approved chain (permissions docs). */
|
|
33
|
+
const MAX_CHAIN_PARTS = 5;
|
|
34
|
+
/** `&&`, `||`, `|&`, `;`, `|`, and a lone `&` that is not part of a redirection (`2>&1`, `&>`). */
|
|
35
|
+
const CHAIN_OPERATOR = /&&|\|\||\|&|;|\||(?<![>&])&(?![>&])/;
|
|
36
|
+
const CHAIN_SPLIT = /\s*(?:&&|\|\||\|&|;|\||(?<![>&])&(?![>&]))\s*/;
|
|
37
|
+
const NESTED_COMMAND = /`|\$\(|[()]/;
|
|
38
|
+
const MCP_TOOL = /^mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_.-]+$/;
|
|
39
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
40
|
+
/** Plan the rules for one denied call. Never throws. */
|
|
41
|
+
function planDeniedCall(toolName, toolInput) {
|
|
42
|
+
const input = (toolInput && typeof toolInput === "object" ? toolInput : {});
|
|
43
|
+
if (toolName === "Bash")
|
|
44
|
+
return planBash(str(input.command).trim());
|
|
45
|
+
if (toolName === "WebFetch")
|
|
46
|
+
return planWebFetch(str(input.url).trim());
|
|
47
|
+
if (MCP_TOOL.test(toolName)) {
|
|
48
|
+
return {
|
|
49
|
+
shown: `${toolName} ${clipJson(input)}`,
|
|
50
|
+
onceRules: [toolName],
|
|
51
|
+
alwaysAllowed: false,
|
|
52
|
+
limitation: "A rule for an MCP tool allows every use of that tool, so BayChat only allows it once.",
|
|
53
|
+
call: { tool: toolName, subject: toolName },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return nothing(`${toolName} ${clipJson(input)}`, "BayChat can only allow shell commands, web fetches and MCP tools from your phone; " +
|
|
57
|
+
"approve this one at the computer (/permissions → Recently denied).");
|
|
58
|
+
}
|
|
59
|
+
function planBash(command) {
|
|
60
|
+
const { text: shown, redacted } = redactSecrets(command);
|
|
61
|
+
if (!command)
|
|
62
|
+
return nothing("(no command)", "The blocked call carried no command.");
|
|
63
|
+
if (command.length > exports.MAX_RULE_COMMAND_LENGTH) {
|
|
64
|
+
return nothing(clip(shown), "It is too long to allow as one exact command; approve it at the computer.");
|
|
65
|
+
}
|
|
66
|
+
if (/[\r\n]/.test(command)) {
|
|
67
|
+
return nothing(shown, "It spans several lines, which no exact rule can describe; approve it at the computer.");
|
|
68
|
+
}
|
|
69
|
+
if (NESTED_COMMAND.test(command)) {
|
|
70
|
+
return nothing(shown, "It runs a command inside another (backticks, $( ) or brackets), which no exact rule can describe; " +
|
|
71
|
+
"approve it at the computer.");
|
|
72
|
+
}
|
|
73
|
+
if (command.includes("*")) {
|
|
74
|
+
return nothing(shown, "It contains *, which Claude Code reads as a wildcard, so a rule would allow more than this command; " +
|
|
75
|
+
"approve it at the computer.");
|
|
76
|
+
}
|
|
77
|
+
const call = { tool: "Bash", subject: command };
|
|
78
|
+
if (CHAIN_OPERATOR.test(command)) {
|
|
79
|
+
if (/["'\\]/.test(command)) {
|
|
80
|
+
return nothing(shown, "It chains several commands and quotes text, so BayChat cannot split it safely; ask the session to " +
|
|
81
|
+
"run them one at a time.");
|
|
82
|
+
}
|
|
83
|
+
const parts = command.split(CHAIN_SPLIT);
|
|
84
|
+
if (parts.some((p) => p === "") || parts.length > MAX_CHAIN_PARTS) {
|
|
85
|
+
return nothing(shown, "BayChat cannot split this chain into exact commands; approve it at the computer.");
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
shown,
|
|
89
|
+
onceRules: [...new Set(parts.map((p) => `Bash(${p})`))],
|
|
90
|
+
alwaysAllowed: false,
|
|
91
|
+
limitation: `It chains ${parts.length} commands, so BayChat only allows it once.`,
|
|
92
|
+
call,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const refusal = redacted
|
|
96
|
+
? "It carries what looks like a secret, and a saved rule would keep it in a settings file, so BayChat only allows it once."
|
|
97
|
+
: command.includes("$")
|
|
98
|
+
? "It expands a variable, so the same text could run something different later; BayChat only allows it once."
|
|
99
|
+
: /[<>]/.test(command)
|
|
100
|
+
? "It redirects to or from a file, so BayChat only allows it once."
|
|
101
|
+
: "";
|
|
102
|
+
return { shown, onceRules: [`Bash(${command})`], alwaysAllowed: refusal === "", limitation: refusal, call };
|
|
103
|
+
}
|
|
104
|
+
function planWebFetch(url) {
|
|
105
|
+
let host = "";
|
|
106
|
+
try {
|
|
107
|
+
host = new URL(url).hostname;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* not a URL: nothing to allow */
|
|
111
|
+
}
|
|
112
|
+
const shown = redactSecrets(url).text;
|
|
113
|
+
if (!host || !/^[A-Za-z0-9.-]+$/.test(host))
|
|
114
|
+
return nothing(shown || "(no URL)", "The blocked fetch has no usable address.");
|
|
115
|
+
return {
|
|
116
|
+
shown,
|
|
117
|
+
onceRules: [`WebFetch(domain:${host})`],
|
|
118
|
+
alwaysAllowed: false,
|
|
119
|
+
limitation: `A WebFetch rule covers the whole of ${host}, so BayChat only allows it once.`,
|
|
120
|
+
call: { tool: "WebFetch", subject: host },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** The key a call is matched by — the same for the denied call and the retried one. */
|
|
124
|
+
function callKeyOf(toolName, toolInput) {
|
|
125
|
+
const input = (toolInput && typeof toolInput === "object" ? toolInput : {});
|
|
126
|
+
if (toolName === "Bash") {
|
|
127
|
+
const command = str(input.command).trim();
|
|
128
|
+
return command ? { tool: "Bash", subject: command } : undefined;
|
|
129
|
+
}
|
|
130
|
+
if (toolName === "WebFetch") {
|
|
131
|
+
try {
|
|
132
|
+
return { tool: "WebFetch", subject: new URL(str(input.url).trim()).hostname };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return MCP_TOOL.test(toolName) ? { tool: toolName, subject: toolName } : undefined;
|
|
139
|
+
}
|
|
140
|
+
function sameCall(a, b) {
|
|
141
|
+
return a.tool === b.tool && a.subject === b.subject;
|
|
142
|
+
}
|
|
143
|
+
function nothing(shown, limitation) {
|
|
144
|
+
return { shown, onceRules: [], alwaysAllowed: false, limitation };
|
|
145
|
+
}
|
|
146
|
+
// ─── Redaction ───────────────────────────────────────────────────────────────
|
|
147
|
+
const MASK = "[redacted]";
|
|
148
|
+
/** `scheme://user:password@host` → `scheme://[redacted]@host`. */
|
|
149
|
+
const URL_CREDENTIALS = /([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
|
|
150
|
+
/** Shapes that are a credential wherever they appear. */
|
|
151
|
+
const KNOWN_TOKENS = /\b(?:bay_[A-Za-z0-9_]{8,}|sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|npm_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g;
|
|
152
|
+
/** `Bearer <x>`, `Basic <x>`, `token <x>` (an Authorization header's value). */
|
|
153
|
+
const AUTH_SCHEME = /\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
154
|
+
/** `--token x`, `--api-key=x`, `-password x`, `--client-secret "x"`, … */
|
|
155
|
+
const SECRET_FLAG = /(--?[A-Za-z0-9_-]*(?:token|secret|passw(?:or)?d|pass|api[-_]?key|apikey|auth|credential|private[-_]?key)[A-Za-z0-9_-]*)(=|\s+)("[^"]*"|'[^']*'|[^\s;&|]+)/gi;
|
|
156
|
+
/** `FOO=…` at the start of a word: an environment assignment, whatever its name. */
|
|
157
|
+
const ENV_ASSIGNMENT = /(^|[\s;&|])([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|[^\s;&|]+)/g;
|
|
158
|
+
/**
|
|
159
|
+
* The text with obvious secrets masked, and whether anything was. Errs towards masking: a card is
|
|
160
|
+
* read on a lock screen and stored in a chat, and a masked word costs nothing but a glance.
|
|
161
|
+
* It is not a guarantee — an unusual secret in a positional argument passes — which is one more
|
|
162
|
+
* reason "Always" is never offered for a command this changes.
|
|
163
|
+
*/
|
|
164
|
+
function redactSecrets(text) {
|
|
165
|
+
const out = text
|
|
166
|
+
.replace(URL_CREDENTIALS, `$1${MASK}@`)
|
|
167
|
+
.replace(KNOWN_TOKENS, MASK)
|
|
168
|
+
.replace(AUTH_SCHEME, `$1 ${MASK}`)
|
|
169
|
+
.replace(SECRET_FLAG, (_m, flag, sep) => `${flag}${sep}${MASK}`)
|
|
170
|
+
.replace(ENV_ASSIGNMENT, (_m, lead, name) => `${lead}${name}=${MASK}`);
|
|
171
|
+
return { text: out, redacted: out !== text };
|
|
172
|
+
}
|
|
173
|
+
function clip(text, max = 600) {
|
|
174
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
175
|
+
}
|
|
176
|
+
function clipJson(value) {
|
|
177
|
+
try {
|
|
178
|
+
return clip(redactSecrets(JSON.stringify(value)).text, 400);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return "(its parameters could not be displayed)";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
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.addAllowRules = addAllowRules;
|
|
37
|
+
exports.removeAllowRules = removeAllowRules;
|
|
38
|
+
exports.localSettingsFile = localSettingsFile;
|
|
39
|
+
// Adding and removing exact `permissions.allow` entries in ONE Claude Code settings file — and
|
|
40
|
+
// touching nothing else in it.
|
|
41
|
+
//
|
|
42
|
+
// THE ONLY EDIT THIS MODULE MAKES is to append a string to `permissions.allow`, or to take a
|
|
43
|
+
// string BayChat put there back out. `deny`, `ask`, `defaultMode`, `hooks` and every other key are
|
|
44
|
+
// carried through untouched, in the file's own indentation. A file that does not parse, is not a
|
|
45
|
+
// JSON object, or has a `permissions`/`allow` of the wrong shape is REFUSED and left exactly as it
|
|
46
|
+
// was: a permission file we half-understood is not one we get to rewrite.
|
|
47
|
+
//
|
|
48
|
+
// Writes are atomic (a temp file beside the target, then a rename — `nodeSettingsIo`, shared with
|
|
49
|
+
// claude-settings.ts) and keep the file's mode. A symlinked settings file is followed, as Claude
|
|
50
|
+
// Code follows it, and a dangling link is refused.
|
|
51
|
+
//
|
|
52
|
+
// WHICH FILE. The project's `.claude/settings.local.json`: the file Claude Code itself saves "Yes,
|
|
53
|
+
// and don't ask again" into, which it watches and reloads during a session, which applies to this
|
|
54
|
+
// project only, and which Claude Code keeps out of git. See `localSettingsFile` for where exactly.
|
|
55
|
+
const child_process_1 = require("child_process");
|
|
56
|
+
const fs = __importStar(require("fs"));
|
|
57
|
+
const os = __importStar(require("os"));
|
|
58
|
+
const path = __importStar(require("path"));
|
|
59
|
+
const claude_settings_1 = require("../claude-settings");
|
|
60
|
+
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
61
|
+
/** Add each rule that is not already allowed. Returns the ones this call added (none: unchanged).
|
|
62
|
+
* @throws with the file untouched when it cannot be read as a settings object. */
|
|
63
|
+
function addAllowRules(file, rules, io = claude_settings_1.nodeSettingsIo) {
|
|
64
|
+
const edit = readSettings(file, io);
|
|
65
|
+
const allow = allowListOf(edit.settings, edit.target);
|
|
66
|
+
const added = rules.filter((rule, i) => !allow.includes(rule) && rules.indexOf(rule) === i);
|
|
67
|
+
if (added.length === 0)
|
|
68
|
+
return [];
|
|
69
|
+
writeAllow(edit, [...allow, ...added], io);
|
|
70
|
+
return added;
|
|
71
|
+
}
|
|
72
|
+
/** Take each rule out of `permissions.allow`. Idempotent: an absent file or rule is already gone.
|
|
73
|
+
* @throws with the file untouched when it cannot be read as a settings object. */
|
|
74
|
+
function removeAllowRules(file, rules, io = claude_settings_1.nodeSettingsIo) {
|
|
75
|
+
const edit = readSettings(file, io);
|
|
76
|
+
if (edit.text === null)
|
|
77
|
+
return;
|
|
78
|
+
const allow = allowListOf(edit.settings, edit.target);
|
|
79
|
+
const kept = allow.filter((rule) => !rules.includes(rule));
|
|
80
|
+
if (kept.length === allow.length)
|
|
81
|
+
return;
|
|
82
|
+
writeAllow(edit, kept, io);
|
|
83
|
+
}
|
|
84
|
+
function readSettings(file, io) {
|
|
85
|
+
const target = io.realpath(file);
|
|
86
|
+
const text = io.read(target);
|
|
87
|
+
if (text === null || text.trim() === "")
|
|
88
|
+
return { target, text, settings: {} };
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(text);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error(`${target} is not valid JSON, so BayChat left it untouched.`);
|
|
95
|
+
}
|
|
96
|
+
if (!isRecord(parsed))
|
|
97
|
+
throw new Error(`${target} is not a JSON object, so BayChat left it untouched.`);
|
|
98
|
+
return { target, text, settings: parsed };
|
|
99
|
+
}
|
|
100
|
+
function allowListOf(settings, target) {
|
|
101
|
+
const permissions = settings.permissions;
|
|
102
|
+
if (permissions === undefined)
|
|
103
|
+
return [];
|
|
104
|
+
if (!isRecord(permissions))
|
|
105
|
+
throw new Error(`${target}: "permissions" is not an object, so BayChat left it untouched.`);
|
|
106
|
+
const allow = permissions.allow;
|
|
107
|
+
if (allow === undefined)
|
|
108
|
+
return [];
|
|
109
|
+
if (!Array.isArray(allow) || !allow.every((r) => typeof r === "string")) {
|
|
110
|
+
throw new Error(`${target}: "permissions.allow" is not a list of rules, so BayChat left it untouched.`);
|
|
111
|
+
}
|
|
112
|
+
return allow;
|
|
113
|
+
}
|
|
114
|
+
function writeAllow(edit, allow, io) {
|
|
115
|
+
const permissions = isRecord(edit.settings.permissions) ? edit.settings.permissions : {};
|
|
116
|
+
const next = { ...edit.settings, permissions: { ...permissions, allow } };
|
|
117
|
+
const indent = edit.text ? (0, claude_settings_1.detectIndent)(edit.text) : 2;
|
|
118
|
+
// A new file is private: a once-rule can carry a command's own arguments.
|
|
119
|
+
io.write(edit.target, `${JSON.stringify(next, null, indent)}\n`, io.mode(edit.target) ?? 0o600);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The `.claude/settings.local.json` Claude Code reads for a session started in `projectDir`.
|
|
123
|
+
*
|
|
124
|
+
* Mirrors the settings docs ("Yes, and don't ask again" → `.claude/settings.local.json`): Claude
|
|
125
|
+
* Code keeps that file at the GIT REPOSITORY ROOT (in a worktree, the main checkout's root), and
|
|
126
|
+
* beside `.claude/settings.json` in the starting directory instead when there is no repository,
|
|
127
|
+
* when the root is the home directory, on Windows, or when the root, its `.git` or its `.claude`
|
|
128
|
+
* is not owned by this user.
|
|
129
|
+
*/
|
|
130
|
+
function localSettingsFile(projectDir, deps = {}) {
|
|
131
|
+
const home = deps.home ?? os.homedir();
|
|
132
|
+
const platform = deps.platform ?? process.platform;
|
|
133
|
+
const ownedByMe = deps.ownedByMe ?? isOwnedByMe;
|
|
134
|
+
const exists = deps.exists ?? fs.existsSync;
|
|
135
|
+
const commonDir = (deps.gitCommonDir ?? gitCommonDir)(projectDir);
|
|
136
|
+
const gitDir = commonDir ? path.resolve(projectDir, commonDir) : undefined;
|
|
137
|
+
const root = gitDir && path.basename(gitDir) === ".git" ? path.dirname(gitDir) : undefined;
|
|
138
|
+
const usable = root !== undefined &&
|
|
139
|
+
platform !== "win32" &&
|
|
140
|
+
path.resolve(root) !== path.resolve(home) &&
|
|
141
|
+
ownedByMe(root) &&
|
|
142
|
+
ownedByMe(path.join(root, ".git")) &&
|
|
143
|
+
(!exists(path.join(root, ".claude")) || ownedByMe(path.join(root, ".claude")));
|
|
144
|
+
return path.join(usable ? root : projectDir, ".claude", "settings.local.json");
|
|
145
|
+
}
|
|
146
|
+
function gitCommonDir(dir) {
|
|
147
|
+
const r = (0, child_process_1.spawnSync)("git", ["rev-parse", "--git-common-dir"], { cwd: dir, encoding: "utf8", timeout: 5_000 });
|
|
148
|
+
if (r.error || r.status !== 0)
|
|
149
|
+
return undefined;
|
|
150
|
+
return String(r.stdout ?? "").trim() || undefined;
|
|
151
|
+
}
|
|
152
|
+
function isOwnedByMe(p) {
|
|
153
|
+
try {
|
|
154
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
155
|
+
return uid === undefined || fs.statSync(p).uid === uid;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|