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,150 @@
|
|
|
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.TRANSCRIPT_WAIT_MS = void 0;
|
|
37
|
+
exports.verifyDeniedCall = verifyDeniedCall;
|
|
38
|
+
exports.findToolUse = findToolUse;
|
|
39
|
+
// Proof that a denial really happened: the denied call must be in the session's own transcript.
|
|
40
|
+
//
|
|
41
|
+
// WHY. `baychat denied-hook` reads its event from stdin, and stdin is something anybody can
|
|
42
|
+
// 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 before
|
|
44
|
+
// anything is posted, the call the event names (`tool_use_id`, `tool_name`, and the command or
|
|
45
|
+
// target) must appear as a tool call the MODEL made, in the transcript Claude Code keeps under
|
|
46
|
+
// `~/.claude/projects/` — a protected path the agent cannot write without a prompt.
|
|
47
|
+
//
|
|
48
|
+
// It is defence in depth, not the boundary: the owner's answer is. A card the agent somehow did
|
|
49
|
+
// cause still writes nothing unless the owner taps a yes.
|
|
50
|
+
//
|
|
51
|
+
// THE TRANSCRIPT CAN LAG. The hooks reference says it "is written asynchronously and may lag the
|
|
52
|
+
// in-memory conversation", so a missing call is polled for, briefly, before it counts as absent.
|
|
53
|
+
// For a subagent's call the hook still gets the MAIN transcript (with `agent_id`); its calls are
|
|
54
|
+
// in `<session>/subagents/agent-<id>.jsonl` beside it.
|
|
55
|
+
const fs = __importStar(require("fs"));
|
|
56
|
+
const os = __importStar(require("os"));
|
|
57
|
+
const path = __importStar(require("path"));
|
|
58
|
+
const permission_rule_1 = require("./permission-rule");
|
|
59
|
+
/** How long a call may take to reach the transcript before it counts as never made. */
|
|
60
|
+
exports.TRANSCRIPT_WAIT_MS = 10_000;
|
|
61
|
+
const TRANSCRIPT_POLL_MS = 250;
|
|
62
|
+
/** Only the end of the file is read: the denied call is the newest thing in it. */
|
|
63
|
+
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
64
|
+
/** Did the model really make this call in this session? NEVER THROWS. */
|
|
65
|
+
async function verifyDeniedCall(ref, deps = {}) {
|
|
66
|
+
const now = deps.now ?? Date.now;
|
|
67
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
68
|
+
const realpath = deps.realpath ?? ((f) => fs.realpathSync(f));
|
|
69
|
+
const readTail = deps.readTail ?? readFileTail;
|
|
70
|
+
try {
|
|
71
|
+
const projects = realpath(path.join(deps.claudeDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"), "projects"));
|
|
72
|
+
const wanted = (0, permission_rule_1.callKeyOf)(ref.toolName, ref.toolInput);
|
|
73
|
+
if (!ref.toolUseId || !wanted)
|
|
74
|
+
return { ok: false, why: "the event names no call that can be checked" };
|
|
75
|
+
if (!path.isAbsolute(ref.transcriptPath) || !ref.transcriptPath.endsWith(".jsonl")) {
|
|
76
|
+
return { ok: false, why: "the event carries no transcript" };
|
|
77
|
+
}
|
|
78
|
+
const file = ref.agentId
|
|
79
|
+
? path.join(ref.transcriptPath.slice(0, -".jsonl".length), "subagents", `${ref.agentId.startsWith("agent-") ? ref.agentId : `agent-${ref.agentId}`}.jsonl`)
|
|
80
|
+
: ref.transcriptPath;
|
|
81
|
+
const giveUpAt = now() + exports.TRANSCRIPT_WAIT_MS;
|
|
82
|
+
for (;;) {
|
|
83
|
+
let real;
|
|
84
|
+
try {
|
|
85
|
+
real = realpath(file);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
real = undefined; // not written yet
|
|
89
|
+
}
|
|
90
|
+
if (real !== undefined && !real.startsWith(projects + path.sep)) {
|
|
91
|
+
return { ok: false, why: "the transcript is not in Claude Code's own folder" };
|
|
92
|
+
}
|
|
93
|
+
const tail = real === undefined ? undefined : readTail(real);
|
|
94
|
+
const found = tail === undefined ? undefined : findToolUse(tail, ref.toolUseId);
|
|
95
|
+
if (found) {
|
|
96
|
+
const key = (0, permission_rule_1.callKeyOf)(found.name, found.input);
|
|
97
|
+
return found.name === ref.toolName && key !== undefined && (0, permission_rule_1.sameCall)(key, wanted)
|
|
98
|
+
? { ok: true }
|
|
99
|
+
: { ok: false, why: "the transcript's call is not the one the event names" };
|
|
100
|
+
}
|
|
101
|
+
if (now() >= giveUpAt)
|
|
102
|
+
return { ok: false, why: "the call is not in the session's transcript" };
|
|
103
|
+
await sleep(TRANSCRIPT_POLL_MS);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return { ok: false, why: `the transcript could not be read (${err instanceof Error ? err.message : String(err)})` };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** The model's tool call with this id, from transcript JSONL text. */
|
|
111
|
+
function findToolUse(text, toolUseId) {
|
|
112
|
+
for (const line of text.split("\n")) {
|
|
113
|
+
if (!line.includes(toolUseId))
|
|
114
|
+
continue;
|
|
115
|
+
let entry;
|
|
116
|
+
try {
|
|
117
|
+
entry = JSON.parse(line);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue; // the cut-off first line of a tail, or a line being written
|
|
121
|
+
}
|
|
122
|
+
const e = entry;
|
|
123
|
+
if (e.type !== "assistant" || !Array.isArray(e.message?.content))
|
|
124
|
+
continue;
|
|
125
|
+
for (const block of e.message.content) {
|
|
126
|
+
if (block?.type === "tool_use" && block.id === toolUseId && typeof block.name === "string") {
|
|
127
|
+
return { name: block.name, input: block.input };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
function readFileTail(file) {
|
|
134
|
+
let fd;
|
|
135
|
+
try {
|
|
136
|
+
fd = fs.openSync(file, "r");
|
|
137
|
+
const size = fs.fstatSync(fd).size;
|
|
138
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
139
|
+
const buf = Buffer.alloc(length);
|
|
140
|
+
fs.readSync(fd, buf, 0, length, size - length);
|
|
141
|
+
return buf.toString("utf8");
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
if (fd !== undefined)
|
|
148
|
+
fs.closeSync(fd);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
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.RELOAD_SETTLE_MS = void 0;
|
|
37
|
+
exports.retryOutput = retryOutput;
|
|
38
|
+
exports.composeDeniedQuestion = composeDeniedQuestion;
|
|
39
|
+
exports.offeredMeanings = offeredMeanings;
|
|
40
|
+
exports.decideDenied = decideDenied;
|
|
41
|
+
exports.cmdDeniedHook = cmdDeniedHook;
|
|
42
|
+
// `baychat denied-hook` — the Claude Code PermissionDenied hook that turns an auto-mode block into
|
|
43
|
+
// a card on the owner's phone, and the owner's "yes" into the one exact permission rule that lets
|
|
44
|
+
// the session run that call.
|
|
45
|
+
//
|
|
46
|
+
// WHY THIS EXISTS. Auto mode's classifier HARD-denies some calls — `npx eas-cli build …`, a release
|
|
47
|
+
// script, `npm publish`. A hard denial draws no prompt, so approve-hook (a PermissionRequest hook)
|
|
48
|
+
// never runs, and the only way through was to walk to the computer and type `/permissions`. Karmen:
|
|
49
|
+
// "We have to have this possibility as people have to use BayChat also when not near computer".
|
|
50
|
+
// See docs/features/DENIED_COMMAND_CARD.md.
|
|
51
|
+
//
|
|
52
|
+
// WHAT CLAUDE CODE GIVES US (hooks reference, "PermissionDenied"): the event fires only in auto
|
|
53
|
+
// mode, after the denial; "Exit code and stderr are ignored because the denial already occurred";
|
|
54
|
+
// the one output read is `hookSpecificOutput.retry: true`, which "adds a message to the
|
|
55
|
+
// conversation telling the model it may retry the tool call" — it does not reverse the denial. So
|
|
56
|
+
// the retry only succeeds because, before printing it, THIS PROCESS (never the agent) writes an
|
|
57
|
+
// allow rule that matches exactly that call into the project's `.claude/settings.local.json`,
|
|
58
|
+
// which Claude Code reloads live and checks before the classifier.
|
|
59
|
+
//
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
// FAIL-CLOSED HERE MEANS "PRINT NOTHING".
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
//
|
|
64
|
+
// Unlike approve-hook, silence is safe: no output leaves the denial standing. Every abnormal path
|
|
65
|
+
// — no credential, no transcript proof, BayChat unreachable, an unanswered card, a settings file
|
|
66
|
+
// we cannot parse, an exception — prints nothing and writes no rule. A rule is written only after
|
|
67
|
+
// the card's owner tapped a yes, and `retry` is printed only after the rule was written.
|
|
68
|
+
//
|
|
69
|
+
// THE PROCESS BLOCKS THE SESSION WHILE IT WAITS, like approve-hook: hooks are awaited, so the
|
|
70
|
+
// session stops until the owner answers or our deadline passes. That is the reliable shape: the
|
|
71
|
+
// rule is in place before the model hears "you may retry", and the retry happens at once, in the
|
|
72
|
+
// same turn. Being killed by the handler timeout is harmless here (the denial stands), but the
|
|
73
|
+
// same 3600 s / 3900 s pair as approve-hook keeps our own deadline first, so the card is withdrawn
|
|
74
|
+
// rather than left looking answerable.
|
|
75
|
+
const child_process_1 = require("child_process");
|
|
76
|
+
const fs = __importStar(require("fs"));
|
|
77
|
+
const os = __importStar(require("os"));
|
|
78
|
+
const path = __importStar(require("path"));
|
|
79
|
+
const approve_hook_1 = require("./approve-hook");
|
|
80
|
+
const approval_wait_1 = require("./approval-wait");
|
|
81
|
+
const config_1 = require("./config");
|
|
82
|
+
const hook_stdin_1 = require("./hook-stdin");
|
|
83
|
+
const grants_1 = require("./denied/grants");
|
|
84
|
+
const permission_rule_1 = require("./denied/permission-rule");
|
|
85
|
+
const settings_rules_1 = require("./denied/settings-rules");
|
|
86
|
+
const transcript_1 = require("./denied/transcript");
|
|
87
|
+
function retryOutput() {
|
|
88
|
+
return { hookSpecificOutput: { hookEventName: "PermissionDenied", retry: true } };
|
|
89
|
+
}
|
|
90
|
+
// ─── The card ────────────────────────────────────────────────────────────────
|
|
91
|
+
const LOG_LABEL = "baychat denied-hook";
|
|
92
|
+
const LABEL = {
|
|
93
|
+
once: "Allow once",
|
|
94
|
+
always: "Always allow this exact command",
|
|
95
|
+
no: "No",
|
|
96
|
+
};
|
|
97
|
+
/** The single button of a card that only informs: nothing can be allowed from BayChat. */
|
|
98
|
+
const NOTICE_OPTIONS = ["OK"];
|
|
99
|
+
/** The card's own limits (apps/api/src/lib/cards.ts), used only to TRIM. */
|
|
100
|
+
const QUESTION_MAX = 2000;
|
|
101
|
+
const SHOWN_MAX = 900;
|
|
102
|
+
/**
|
|
103
|
+
* Give Claude Code's settings watcher this long to load the new rule before the model is told to
|
|
104
|
+
* retry. Claude Code does not say how fast its watcher is; this is a margin, and a retry that
|
|
105
|
+
* still meets the old settings is handled (see `MAX_SILENT_RETRIES`).
|
|
106
|
+
*/
|
|
107
|
+
exports.RELOAD_SETTLE_MS = 1500;
|
|
108
|
+
/** A call denied AGAIN while a grant already covers it is retried this many times without a new
|
|
109
|
+
* card (the watcher was slower than RELOAD_SETTLE_MS). After that the rule evidently does not
|
|
110
|
+
* match how Claude Code reads the call, and the owner is told so. */
|
|
111
|
+
const MAX_SILENT_RETRIES = 1;
|
|
112
|
+
/** The classifier gave no verdict: Claude Code ignores `retry` for these, and a rule written for a
|
|
113
|
+
* transient failure would outlive it. Texts from the hooks reference. */
|
|
114
|
+
function isNoVerdict(reason) {
|
|
115
|
+
return reason.startsWith("Auto mode could not evaluate") || reason === "Classifier unavailable";
|
|
116
|
+
}
|
|
117
|
+
/** The card's question. A person decides from this text alone, on a lock screen. */
|
|
118
|
+
function composeDeniedQuestion(q) {
|
|
119
|
+
const shown = q.plan.shown.length <= SHOWN_MAX ? q.plan.shown : `${q.plan.shown.slice(0, SHOWN_MAX - 1)}…`;
|
|
120
|
+
const offers = q.plan.onceRules.length
|
|
121
|
+
? [
|
|
122
|
+
`Allow once: BayChat adds ${q.plan.onceRules.length === 1 ? "this exact rule" : "these exact rules"} to ` +
|
|
123
|
+
`${q.settingsFile} for the retry, then takes ${q.plan.onceRules.length === 1 ? "it" : "them"} out.`,
|
|
124
|
+
q.plan.alwaysAllowed ? "Always: BayChat keeps the rule there (`baychat permissions list` shows it)." : "",
|
|
125
|
+
]
|
|
126
|
+
: [];
|
|
127
|
+
const lines = [
|
|
128
|
+
`${q.who} on ${q.host} was blocked by Claude Code's auto mode:`,
|
|
129
|
+
"",
|
|
130
|
+
shown,
|
|
131
|
+
"",
|
|
132
|
+
q.reason ? `Reason: ${q.reason}` : "",
|
|
133
|
+
q.cwd ? `Working directory: ${q.cwd}` : "",
|
|
134
|
+
"",
|
|
135
|
+
...offers,
|
|
136
|
+
q.plan.limitation,
|
|
137
|
+
];
|
|
138
|
+
const text = lines.filter((l) => l !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
139
|
+
return text.length <= QUESTION_MAX ? text : `${text.slice(0, QUESTION_MAX - 1)}…`;
|
|
140
|
+
}
|
|
141
|
+
/** The buttons for a plan: every option it allows, and always a way to say no. */
|
|
142
|
+
function offeredMeanings(plan) {
|
|
143
|
+
if (plan.onceRules.length === 0)
|
|
144
|
+
return [];
|
|
145
|
+
return plan.alwaysAllowed ? ["once", "always", "no"] : ["once", "no"];
|
|
146
|
+
}
|
|
147
|
+
function parseArgs(argv) {
|
|
148
|
+
const at = (name) => {
|
|
149
|
+
const i = argv.indexOf(name);
|
|
150
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
151
|
+
};
|
|
152
|
+
const rawTimeout = Number(at("--timeout"));
|
|
153
|
+
return {
|
|
154
|
+
session: at("--session"),
|
|
155
|
+
fromClaude: argv.includes("--session-from-claude"),
|
|
156
|
+
waitSeconds: Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : approve_hook_1.DEFAULT_WAIT_SECONDS,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
160
|
+
/**
|
|
161
|
+
* Turn one PermissionDenied event into `retry` — or into nothing, which leaves the denial
|
|
162
|
+
* standing. NEVER THROWS. `retry` is returned only after the owner allowed the call and the rule
|
|
163
|
+
* that matches it is written.
|
|
164
|
+
*/
|
|
165
|
+
async function decideDenied(rawStdin, argv = [], deps = {}) {
|
|
166
|
+
const now = deps.now ?? Date.now;
|
|
167
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
168
|
+
const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
169
|
+
try {
|
|
170
|
+
const opts = parseArgs(argv);
|
|
171
|
+
let input;
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(rawStdin);
|
|
174
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
175
|
+
throw new Error("not an object");
|
|
176
|
+
input = parsed;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
log(`${LOG_LABEL}: the event could not be read from stdin; the denial stands.`);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (input.hook_event_name !== "PermissionDenied") {
|
|
183
|
+
log(`${LOG_LABEL}: not a PermissionDenied event (${String(input.hook_event_name)}); the denial stands.`);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const reason = str(input.reason);
|
|
187
|
+
if (isNoVerdict(reason)) {
|
|
188
|
+
log(`${LOG_LABEL}: auto mode gave no verdict ("${reason}"), so Claude Code would ignore a retry.`);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const toolName = str(input.tool_name);
|
|
192
|
+
const claudeSessionId = str(input.session_id);
|
|
193
|
+
const call = (0, permission_rule_1.callKeyOf)(toolName, input.tool_input);
|
|
194
|
+
if (!toolName || !claudeSessionId) {
|
|
195
|
+
log(`${LOG_LABEL}: the event has no tool or session (keys: ${Object.keys(input).join(", ")}).`);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const session = opts.session ?? (opts.fromClaude ? (0, approve_hook_1.sessionFromClaude)(deps.claude) : undefined);
|
|
199
|
+
if (opts.fromClaude && session === undefined) {
|
|
200
|
+
log(`${LOG_LABEL}: this session has not joined BayChat yet; the denial stands.`);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const device = (deps.loadDevice ?? config_1.loadDeviceCredentials)();
|
|
204
|
+
if (!device) {
|
|
205
|
+
log(`${LOG_LABEL}: this machine is not logged in to BayChat (\`baychat login\`); the denial stands.`);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
// Only a call the model really made may put a card on anybody's phone.
|
|
209
|
+
const proof = await (deps.verify ?? transcript_1.verifyDeniedCall)({
|
|
210
|
+
transcriptPath: str(input.transcript_path),
|
|
211
|
+
agentId: str(input.agent_id) || undefined,
|
|
212
|
+
toolUseId: str(input.tool_use_id),
|
|
213
|
+
toolName,
|
|
214
|
+
toolInput: input.tool_input,
|
|
215
|
+
});
|
|
216
|
+
if (!proof.ok) {
|
|
217
|
+
log(`${LOG_LABEL}: ${proof.why}; no card was posted and the denial stands.`);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const host = (deps.hostname ?? os.hostname)();
|
|
221
|
+
const who = session ? `Session "${session}"` : "A Claude Code session";
|
|
222
|
+
const waitDeps = { now, sleep, log };
|
|
223
|
+
// A card that only informs: posted, never waited on (its deadline is already now).
|
|
224
|
+
const notify = (question) => (0, approval_wait_1.askOwnerAndWait)(device, { session, question, options: NOTICE_OPTIONS }, now(), waitDeps, LOG_LABEL);
|
|
225
|
+
// ── Already allowed, and denied again: the reload race, or a rule that does not match.
|
|
226
|
+
const covering = call ? (0, grants_1.coveringGrant)(claudeSessionId, call, now()) : undefined;
|
|
227
|
+
if (covering) {
|
|
228
|
+
if (covering.retries < MAX_SILENT_RETRIES) {
|
|
229
|
+
await (0, grants_1.noteRetry)(covering.id);
|
|
230
|
+
await sleep(exports.RELOAD_SETTLE_MS * 2);
|
|
231
|
+
return retryOutput();
|
|
232
|
+
}
|
|
233
|
+
if (covering.kind === "once")
|
|
234
|
+
await (0, grants_1.revokeGrant)(covering.id);
|
|
235
|
+
await notify(`${who} on ${host} is still blocked although you allowed it:\n\n${(0, permission_rule_1.planDeniedCall)(toolName, input.tool_input).shown}\n\n` +
|
|
236
|
+
"Claude Code did not accept the rule BayChat wrote for it. Approve it at the computer " +
|
|
237
|
+
"(/permissions → Recently denied).");
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const projectDir = deps.projectDir ?? (process.env.CLAUDE_PROJECT_DIR || str(input.cwd));
|
|
241
|
+
if (!projectDir || !path.isAbsolute(projectDir)) {
|
|
242
|
+
log(`${LOG_LABEL}: no project directory to keep a rule in; the denial stands.`);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const settingsFile = (deps.settingsFileFor ?? settings_rules_1.localSettingsFile)(projectDir);
|
|
246
|
+
const plan = (0, permission_rule_1.planDeniedCall)(toolName, input.tool_input);
|
|
247
|
+
const question = composeDeniedQuestion({ who, host, plan, reason, cwd: str(input.cwd), settingsFile });
|
|
248
|
+
const meanings = offeredMeanings(plan);
|
|
249
|
+
if (meanings.length === 0) {
|
|
250
|
+
// Nothing can be allowed from BayChat; the owner still learns why the session stopped.
|
|
251
|
+
await notify(question);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const deadline = now() + opts.waitSeconds * 1000;
|
|
255
|
+
const outcome = await (0, approval_wait_1.askOwnerAndWait)(device, {
|
|
256
|
+
session,
|
|
257
|
+
question,
|
|
258
|
+
options: meanings.map((m) => LABEL[m]),
|
|
259
|
+
guardedOptions: meanings.flatMap((m, i) => (m === "always" ? [i] : [])),
|
|
260
|
+
}, deadline, waitDeps, LOG_LABEL);
|
|
261
|
+
if (outcome.kind === "deadline") {
|
|
262
|
+
await (0, approval_wait_1.withdrawOwnerQuestion)(device, outcome.requestId, log);
|
|
263
|
+
log(`${LOG_LABEL}: nobody answered within ${opts.waitSeconds}s; the card was withdrawn and the denial stands.`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
if (outcome.kind !== "answered") {
|
|
267
|
+
log(`${LOG_LABEL}: ${outcome.kind === "failed" ? outcome.reason : outcome.kind}; the denial stands.`);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const meaning = (outcome.index !== null ? meanings[outcome.index] : undefined) ?? "no";
|
|
271
|
+
if (meaning === "no" || !plan.call)
|
|
272
|
+
return null;
|
|
273
|
+
let grant;
|
|
274
|
+
try {
|
|
275
|
+
grant = await (0, grants_1.grantRules)({
|
|
276
|
+
kind: meaning,
|
|
277
|
+
rules: plan.onceRules,
|
|
278
|
+
settingsFile,
|
|
279
|
+
call: plan.call,
|
|
280
|
+
shown: plan.shown,
|
|
281
|
+
claudeSessionId,
|
|
282
|
+
session,
|
|
283
|
+
}, now());
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
log(`${LOG_LABEL}: the rule could not be written (${(0, approval_wait_1.reasonOf)(err)}); the denial stands.`);
|
|
287
|
+
await notify(`BayChat could not save the rule you allowed, so ${who.toLowerCase()} stays blocked:\n\n${(0, approval_wait_1.reasonOf)(err)}`);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (grant.kind === "once" && grant.expiresAt) {
|
|
291
|
+
(deps.scheduleSweep ?? scheduleSweep)(Date.parse(grant.expiresAt));
|
|
292
|
+
}
|
|
293
|
+
await sleep(exports.RELOAD_SETTLE_MS);
|
|
294
|
+
return retryOutput();
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
log(`${LOG_LABEL}: unexpected failure (${(0, approval_wait_1.reasonOf)(err)}); the denial stands.`);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Start `baychat permissions sweep --at <ms>` detached, so a once-rule is taken back at its TTL
|
|
303
|
+
* even when the retried call never reports back and no other hook runs. Best effort: the
|
|
304
|
+
* after-hook and every later hook run sweep too.
|
|
305
|
+
*/
|
|
306
|
+
function scheduleSweep(atMs) {
|
|
307
|
+
try {
|
|
308
|
+
const entry = fs.realpathSync(process.argv[1] ?? "");
|
|
309
|
+
const child = (0, child_process_1.spawn)(process.execPath, [entry, "permissions", "sweep", "--at", String(atMs)], {
|
|
310
|
+
detached: true,
|
|
311
|
+
stdio: "ignore",
|
|
312
|
+
});
|
|
313
|
+
child.unref();
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
process.stderr.write(`${LOG_LABEL}: could not schedule the once-rule's removal (${(0, approval_wait_1.reasonOf)(err)}).\n`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* `baychat denied-hook [--session <name> | --session-from-claude] [--timeout <seconds>]`
|
|
321
|
+
*
|
|
322
|
+
* Always returns 0 (Claude Code ignores this event's exit code). Prints `retry` or nothing.
|
|
323
|
+
*/
|
|
324
|
+
async function cmdDeniedHook(argv = [], stdin = process.stdin) {
|
|
325
|
+
const output = await decideDenied(await (0, hook_stdin_1.readStdin)(stdin), argv);
|
|
326
|
+
if (output)
|
|
327
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
package/dist/help-topics.js
CHANGED
|
@@ -136,10 +136,11 @@ A session is one terminal, named by you. It appears in the app as an agent your
|
|
|
136
136
|
messages can reach, and it survives being parked.
|
|
137
137
|
|
|
138
138
|
join_session({ session: "Session-A" }) → a 1:1 with you
|
|
139
|
-
join_session({ session: "Session-A", group: "Ad Review" }) → that group
|
|
139
|
+
join_session({ session: "Session-A", group: "Ad Review" }) → that group only
|
|
140
140
|
|
|
141
|
-
**
|
|
142
|
-
session name
|
|
141
|
+
**A group join creates no private chat.** If you already have one with this
|
|
142
|
+
session (same agent, same name), the result names it as directConversationId;
|
|
143
|
+
otherwise there is none until you start one.
|
|
143
144
|
|
|
144
145
|
list_sessions() → name, live or idle, last seen
|
|
145
146
|
end_session(...) → park it; the chat and its history survive, and rejoining
|
|
@@ -167,7 +168,14 @@ a decision that expires silently is worse than one that waits.
|
|
|
167
168
|
cards. It is on only for Claude Code sessions BayChat starts (from the phone,
|
|
168
169
|
\`baychat claude\` or \`baychat start\`), never for a plain \`claude\`, and it moves
|
|
169
170
|
the last line between an agent and your machine onto a phone — read
|
|
170
|
-
docs/features/REMOTE_APPROVAL_HOOK.md. It fails CLOSED: every error denies
|
|
171
|
+
docs/features/REMOTE_APPROVAL_HOOK.md. It fails CLOSED: every error denies.
|
|
172
|
+
|
|
173
|
+
When auto mode blocks a call outright there is no prompt to answer. With
|
|
174
|
+
\`baychat permissions on\` (off by default), BayChat-started sessions then put a
|
|
175
|
+
card in BayChat — Allow once / Always allow this exact command / No — and a yes
|
|
176
|
+
writes that one exact rule to the project's .claude/settings.local.json and lets
|
|
177
|
+
the session retry. \`baychat permissions\` lists and removes what it kept. See
|
|
178
|
+
docs/features/DENIED_COMMAND_CARD.md.`,
|
|
171
179
|
};
|
|
172
180
|
exports.HELP_TOPICS = [GROUPS, SESSIONS, TOOLS, APPROVALS];
|
|
173
181
|
/** Exact name first, then keyword, then a substring of the body. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// A Claude Code hook's JSON input, read from stdin with a deadline. Imports nothing but Node, so
|
|
3
|
+
// the fast-path hooks (denied/after-hook.ts) can use it without loading the CLI.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.readStdin = readStdin;
|
|
6
|
+
/**
|
|
7
|
+
* The hook's JSON input.
|
|
8
|
+
*
|
|
9
|
+
* Bounded, because a stdin that never closes would stall us until the handler timeout kills the
|
|
10
|
+
* process — and for approve-hook being killed ALLOWS. On timeout we return what arrived (usually
|
|
11
|
+
* nothing), which fails to parse, which each hook turns into its own fail-closed answer.
|
|
12
|
+
*/
|
|
13
|
+
function readStdin(stream = process.stdin, timeoutMs = 10_000) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
let text = "";
|
|
16
|
+
let done = false;
|
|
17
|
+
const finish = () => {
|
|
18
|
+
if (done)
|
|
19
|
+
return;
|
|
20
|
+
done = true;
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
resolve(text);
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
25
|
+
// Do not hold the process open on the timer alone.
|
|
26
|
+
if (typeof timer.unref === "function")
|
|
27
|
+
timer.unref();
|
|
28
|
+
stream.setEncoding?.("utf8");
|
|
29
|
+
stream.on("data", (chunk) => {
|
|
30
|
+
text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
31
|
+
});
|
|
32
|
+
stream.on("end", finish);
|
|
33
|
+
stream.on("error", finish);
|
|
34
|
+
});
|
|
35
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
/** Kept in step with `FAST_CONTEXT_COMMANDS` in claude-context.ts (a test pins the two). */
|
|
13
13
|
const FAST_COMMANDS = new Set(["context-statusline", "context-hook", "context-flush"]);
|
|
14
14
|
const command = process.argv[2];
|
|
15
|
-
if (command
|
|
15
|
+
if (command === "denied-after") {
|
|
16
|
+
// The denied-command card's after-hook runs on every matching tool call (as an async hook), so
|
|
17
|
+
// it too must not load the CLI. It imports only its own files and Node.
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
const after = require("./denied/after-hook");
|
|
20
|
+
after.cmdDeniedAfter().then((code) => {
|
|
21
|
+
process.exitCode = code;
|
|
22
|
+
}, (err) => {
|
|
23
|
+
process.stderr.write(`baychat denied-after: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
24
|
+
process.exitCode = 0;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
else if (command !== undefined && FAST_COMMANDS.has(command)) {
|
|
16
28
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
17
29
|
const fast = require("./claude-context");
|
|
18
30
|
fast.runFastContextCommand(command, process.argv.slice(3)).then(
|
package/dist/main.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const commands_1 = require("./commands");
|
|
5
5
|
const approve_hook_1 = require("./approve-hook");
|
|
6
|
+
const denied_hook_1 = require("./denied-hook");
|
|
7
|
+
const permissions_command_1 = require("./permissions-command");
|
|
6
8
|
const context_hooks_command_1 = require("./context-hooks-command");
|
|
7
9
|
const session_name_1 = require("./session-name");
|
|
8
10
|
const start_command_1 = require("./start-command");
|
|
@@ -301,6 +303,13 @@ async function main() {
|
|
|
301
303
|
// exit-1 hook with no JSON on stdout is a NON-BLOCKING error: the tool call proceeds. The
|
|
302
304
|
// guard in the catch below covers that anyway.
|
|
303
305
|
return await (0, approve_hook_1.cmdApproveHook)(args);
|
|
306
|
+
case "denied-hook":
|
|
307
|
+
// Never throws and always returns 0; prints `retry` or nothing (see denied-hook.ts). For
|
|
308
|
+
// this event Claude Code ignores the exit code, and printing nothing leaves the denial
|
|
309
|
+
// standing — so a crash here is already the safe outcome.
|
|
310
|
+
return await (0, denied_hook_1.cmdDeniedHook)(args);
|
|
311
|
+
case "permissions":
|
|
312
|
+
return await (0, permissions_command_1.cmdPermissions)(args);
|
|
304
313
|
case "context-hooks":
|
|
305
314
|
return await (0, context_hooks_command_1.cmdContextHooks)(args);
|
|
306
315
|
case "doctor":
|