@bridge_gpt/mcp-server 0.2.67 → 0.2.69
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/README.md +272 -51
- package/build/agent-launchers/claude-executor-adapter.js +27 -0
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bridge-permissions.js +1362 -0
- package/build/check-update-cli.js +74 -0
- package/build/claude-settings.js +102 -2
- package/build/cli-help.js +27 -0
- package/build/codex-home.js +9 -1
- package/build/codex-launcher-pin.js +598 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +9 -9
- package/build/conductor/bridge-api-client.js +714 -0
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +69 -26
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +76 -22
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/readiness-cli.js +95 -10
- package/build/conductor/readiness-sections.js +369 -3
- package/build/conductor/readiness.js +211 -8
- package/build/conductor/recovery-cli.js +110 -4
- package/build/conductor/recovery-operations.js +99 -1
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/connect-github-api.js +33 -14
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +57 -3
- package/build/doctor.js +198 -4
- package/build/drive-epic.js +45 -8
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1369 -25
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +30 -0
- package/build/executor/completion-contract.js +20 -5
- package/build/executor/deps.js +4 -1
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/http-client.js +43 -4
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +8 -1
- package/build/executor/job-runner.js +589 -47
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +31 -2
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +61 -12
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +105 -17
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +114 -11
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +31 -5
- package/build/executor/worker-guard-hook.js +179 -25
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +88 -11
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +1109 -695
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +289 -36
- package/build/install-bridge.js +722 -252
- package/build/launcher-config-inspection.js +184 -19
- package/build/mcp-host-targets.js +4 -0
- package/build/mcp-identity.js +7 -0
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +22 -142
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/manifest.js +16 -1
- package/build/plane/preflight.js +54 -1
- package/build/printed-remedy-commands.js +16 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/session-upgrade-notice.js +86 -0
- package/build/setup-epic.js +108 -3
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +44 -5
- package/build/tool-call-advisory.js +162 -0
- package/build/tool-surface-gating.js +32 -57
- package/build/update-check.js +97 -16
- package/build/update-status.js +61 -71
- package/build/upgrade-advice.js +15 -11
- package/build/upgrade-cli.js +398 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +3 -2
- package/build/worker-guard-hook-main.js +1 -1
- package/build/worktree-core.js +15 -2
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
- package/docs/EPIC-IMPLEMENTER.md +68 -2
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge-managed Claude Code permission rules for the `/drive-epic-run`
|
|
3
|
+
* supervisor (BAPI-1398, AC-8), and the one pinned SessionStart upgrade-notice
|
|
4
|
+
* hook (BAPI-1446).
|
|
5
|
+
*
|
|
6
|
+
* The supervisor runs in auto mode. Every in-scope action it takes — a
|
|
7
|
+
* sanctioned CLI verb, a Bridge MCP tool, a read, a tally write — must resolve
|
|
8
|
+
* without a permission prompt or a classifier denial. Narrow
|
|
9
|
+
* `permissions.allow` rules resolve before the auto-mode classifier, so the
|
|
10
|
+
* installer writes one exact rule for every shell and tool form the posture
|
|
11
|
+
* prescribes, into the project's LOCAL settings file,
|
|
12
|
+
* `.claude/settings.local.json`. Claude Code reads that file as project-scoped
|
|
13
|
+
* settings, and it is per developer and gitignored. The rules must never go into
|
|
14
|
+
* the committed `.claude/settings.json`: they can hold a developer's own
|
|
15
|
+
* launcher path, they belong to one developer's manifest, and a committed allow
|
|
16
|
+
* rule would widen every teammate's sessions. The MCP tool rules are written
|
|
17
|
+
* under the key the server is actually registered as in `.mcp.json` (canonical
|
|
18
|
+
* `bridge`, or a kept legacy registration key), so they match the tool
|
|
19
|
+
* names Claude Code really sees. The auto-mode
|
|
20
|
+
* classifier ignores `autoMode` in project settings, so the prose
|
|
21
|
+
* `autoMode.allow` / `autoMode.environment` entries go into the USER's
|
|
22
|
+
* `~/.claude/settings.json` — and only with the user's explicit consent. Install
|
|
23
|
+
* never asks for it (BAPI-1448): the `bridge-permissions` subcommand
|
|
24
|
+
* ({@link runBridgePermissionsCli}) owns that write, and `/drive-epic-run` offers
|
|
25
|
+
* it in-session only when the entries are missing.
|
|
26
|
+
*
|
|
27
|
+
* Ownership. A JSON string in an allow list cannot carry a marker, and Claude
|
|
28
|
+
* Code's settings loader is not given unknown keys. Bridge therefore records the
|
|
29
|
+
* exact strings it inserted in its own manifest, `.bridge/bridge-permissions.json`
|
|
30
|
+
* (gitignored, per developer). `doctor` reads it to report present / missing /
|
|
31
|
+
* drifted, and removal deletes exactly those strings. A string the user already
|
|
32
|
+
* had before Bridge wrote it is never claimed, so removal never takes it away.
|
|
33
|
+
* The manifest is separate from `.bridge/install-state.json` because that file's
|
|
34
|
+
* contract forbids absolute home paths, and a local launcher override is often
|
|
35
|
+
* one.
|
|
36
|
+
*
|
|
37
|
+
* File discipline matches `claude-settings.ts`: a missing settings file is `{}`;
|
|
38
|
+
* a malformed one is reported as `malformed` and never written; changed files
|
|
39
|
+
* are written with a 2-space indent and one trailing newline; an unchanged file
|
|
40
|
+
* is not rewritten, so a second run leaves every file byte-identical.
|
|
41
|
+
*
|
|
42
|
+
* Never broad: no `Bash(*)`, no wildcard launcher, and no rule that covers a
|
|
43
|
+
* credential file. A launcher override that cannot be written as an exact,
|
|
44
|
+
* secret-free command prefix is refused rather than approximated.
|
|
45
|
+
*
|
|
46
|
+
* The SessionStart hook (BAPI-1446) lives in the same local settings file and is
|
|
47
|
+
* owned the same way: the manifest's `hooks.SessionStart` records the one exact
|
|
48
|
+
* command Bridge wrote, removal deletes exactly that command, and permission and
|
|
49
|
+
* hook changes share one settings snapshot so neither overwrites the other.
|
|
50
|
+
*/
|
|
51
|
+
import fs from "node:fs/promises";
|
|
52
|
+
import os from "node:os";
|
|
53
|
+
import path from "node:path";
|
|
54
|
+
import readline from "node:readline";
|
|
55
|
+
import { inspectClaudeHookContainers, mergeClaudeSettingsWithCommandHookChecked, removeClaudeSettingsCommandHook, } from "./claude-settings.js";
|
|
56
|
+
import { MCP_PACKAGE_NAME, MCP_SERVER_NAME, resolveRegistrationKey } from "./mcp-identity.js";
|
|
57
|
+
import { isPlainReleaseVersion } from "./update-check.js";
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Constants
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
/** The environment variable the posture reads for a local launcher override. */
|
|
62
|
+
export const BRIDGE_LAUNCHER_ENV = "BAPI_MCP_CLI";
|
|
63
|
+
/** The default packaged launcher prefix — byte-identical to the posture's default. */
|
|
64
|
+
export const BRIDGE_DEFAULT_LAUNCHER = `npx -y ${MCP_PACKAGE_NAME}`;
|
|
65
|
+
/** Repository-relative path of Bridge's permission-ownership manifest. */
|
|
66
|
+
export const BRIDGE_PERMISSIONS_MANIFEST_RELPATH = ".bridge/bridge-permissions.json";
|
|
67
|
+
/**
|
|
68
|
+
* Repository-relative path of the project settings file the rules go into. It is
|
|
69
|
+
* the per-developer, gitignored local file — never the committed
|
|
70
|
+
* `.claude/settings.json`.
|
|
71
|
+
*/
|
|
72
|
+
export const BRIDGE_PROJECT_SETTINGS_RELPATH = ".claude/settings.local.json";
|
|
73
|
+
/** Current manifest schema version. */
|
|
74
|
+
export const BRIDGE_PERMISSIONS_MANIFEST_VERSION = 1;
|
|
75
|
+
/** The Claude Code hook event the upgrade notice runs on. */
|
|
76
|
+
export const SESSION_START_HOOK_EVENT = "SessionStart";
|
|
77
|
+
/** The exact arguments after the launcher: the internal offline check command. */
|
|
78
|
+
export const SESSION_START_HOOK_SUFFIX = "check-update --hook claude-session-start";
|
|
79
|
+
/** Run only on a fresh session start, not on resume, clear, or compact. */
|
|
80
|
+
export const SESSION_START_HOOK_MATCHER = "startup";
|
|
81
|
+
/** Seconds Claude Code waits for the hook. A cold `npx` launch can exceed it. */
|
|
82
|
+
export const SESSION_START_HOOK_TIMEOUT_SECONDS = 5;
|
|
83
|
+
/** The auto-mode sentinel that keeps Claude Code's built-in entries in a list. */
|
|
84
|
+
export const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
|
|
85
|
+
/**
|
|
86
|
+
* The sanctioned recovery verbs, as their packaged CLI subcommands, in
|
|
87
|
+
* the posture's verb-table order. Adding a verb here without adding it to the
|
|
88
|
+
* posture's verb table (or the reverse) is caught by the static coverage test.
|
|
89
|
+
*/
|
|
90
|
+
export const BRIDGE_SANCTIONED_VERB_SUBCOMMANDS = [
|
|
91
|
+
"unpark",
|
|
92
|
+
"continue-child",
|
|
93
|
+
"grant-continuation",
|
|
94
|
+
"adopt-current-head-and-unpark",
|
|
95
|
+
"stop-run",
|
|
96
|
+
"abandon-run",
|
|
97
|
+
"scope-reparse",
|
|
98
|
+
"dispatch-remediation",
|
|
99
|
+
"merge-child",
|
|
100
|
+
"scope-catch-up",
|
|
101
|
+
"patch-policy",
|
|
102
|
+
"resume-run",
|
|
103
|
+
"resume-ci",
|
|
104
|
+
"rerun-ci",
|
|
105
|
+
];
|
|
106
|
+
/** The Bridge MCP tools the supervisor calls. */
|
|
107
|
+
export const BRIDGE_SUPERVISOR_MCP_TOOLS = [
|
|
108
|
+
"ping",
|
|
109
|
+
"explain_run",
|
|
110
|
+
"get_epic_snapshot",
|
|
111
|
+
"poll_ci_checks",
|
|
112
|
+
"second_opinion",
|
|
113
|
+
"report_mcp_bug",
|
|
114
|
+
// BAPI-1399: the prerequisite scan reads the epic and its child tickets.
|
|
115
|
+
"get_ticket",
|
|
116
|
+
];
|
|
117
|
+
/** The private run-state directory the tally, patch, and context files live in. */
|
|
118
|
+
export const BRIDGE_RUN_STATE_DIR = "~/.local/state/bapi-conductor-runs";
|
|
119
|
+
/**
|
|
120
|
+
* Launcher override characters that cannot be part of an exact, safe rule
|
|
121
|
+
* prefix: shell operators, quotes (the posture writes the launcher unquoted),
|
|
122
|
+
* rule-pattern metacharacters, an environment assignment (`=`), and a home
|
|
123
|
+
* shorthand that the shell would expand differently from the rule.
|
|
124
|
+
*/
|
|
125
|
+
const UNSAFE_LAUNCHER_PATTERN = /[;&|<>$`\\'"*?()[\]{}=~\n\r\t\0]/;
|
|
126
|
+
/** Upper bound on a launcher override, so a pasted blob is never a rule. */
|
|
127
|
+
const MAX_LAUNCHER_CHARS = 256;
|
|
128
|
+
/** Tokens that suggest a credential was pasted into the launcher value. */
|
|
129
|
+
const SECRET_LIKE_PATTERN = /(api[_-]?key|token|secret|passw(or)?d|bearer|bapi_[a-z0-9]{8,})/i;
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the launcher prefix exactly as the posture resolves it: unset, empty,
|
|
132
|
+
* or whitespace-only means the default packaged launcher; anything else is used
|
|
133
|
+
* verbatim. Internal runs of whitespace collapse to one space, because that is
|
|
134
|
+
* how the posture writes the launcher's words. An override that cannot be an
|
|
135
|
+
* exact, secret-free prefix is refused — never approximated with a wildcard.
|
|
136
|
+
* The refusal reason never echoes the value.
|
|
137
|
+
*/
|
|
138
|
+
export function resolvePermissionLauncher(env) {
|
|
139
|
+
const raw = env[BRIDGE_LAUNCHER_ENV];
|
|
140
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
141
|
+
return { ok: true, prefix: BRIDGE_DEFAULT_LAUNCHER, source: "default" };
|
|
142
|
+
}
|
|
143
|
+
const trimmed = raw.trim();
|
|
144
|
+
if (trimmed.length > MAX_LAUNCHER_CHARS) {
|
|
145
|
+
return { ok: false, reason: `${BRIDGE_LAUNCHER_ENV} is longer than ${MAX_LAUNCHER_CHARS} characters` };
|
|
146
|
+
}
|
|
147
|
+
if (UNSAFE_LAUNCHER_PATTERN.test(trimmed)) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: `${BRIDGE_LAUNCHER_ENV} contains a quote, shell operator, wildcard, or assignment, ` +
|
|
151
|
+
"so it cannot be matched by an exact permission rule",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (SECRET_LIKE_PATTERN.test(trimmed)) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: `${BRIDGE_LAUNCHER_ENV} looks like it carries a credential, which must never be part of a rule`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { ok: true, prefix: trimmed.split(/\s+/).join(" "), source: "override" };
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Rule catalogs
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
/**
|
|
166
|
+
* The project `permissions.allow` catalog for one launcher prefix. Every entry is
|
|
167
|
+
* an exact command or an exact prefix followed by its arguments — never a broad
|
|
168
|
+
* rule. `serverNames` are the MCP registration keys the tool rules are written
|
|
169
|
+
* under (see {@link resolveSupervisorServerNames}). Order is stable so output and
|
|
170
|
+
* the manifest are deterministic.
|
|
171
|
+
*/
|
|
172
|
+
export function buildProjectPermissionRules(launcherPrefix, serverNames = [MCP_SERVER_NAME]) {
|
|
173
|
+
const rules = [];
|
|
174
|
+
// The sanctioned verbs, in the launcher's exact form.
|
|
175
|
+
for (const verb of BRIDGE_SANCTIONED_VERB_SUBCOMMANDS) {
|
|
176
|
+
rules.push(`Bash(${launcherPrefix} epic-implementer ${verb} *)`);
|
|
177
|
+
}
|
|
178
|
+
// drive-epic preview and attach, and the readiness reads.
|
|
179
|
+
rules.push(`Bash(${launcherPrefix} drive-epic *)`);
|
|
180
|
+
rules.push(`Bash(${launcherPrefix} epic-implementer readiness *)`);
|
|
181
|
+
// Read-only supporting commands.
|
|
182
|
+
rules.push(`Bash(${launcherPrefix} epic-implementer continuation-receipt *)`);
|
|
183
|
+
rules.push(`Bash(${launcherPrefix} epic-implementer doctor)`);
|
|
184
|
+
rules.push(`Bash(${launcherPrefix} plane status)`);
|
|
185
|
+
// BAPI-1448: the read-only user-scope check `/drive-epic-run` runs first. There is
|
|
186
|
+
// deliberately NO rule for `--user-consented`: Claude Code's own permission prompt
|
|
187
|
+
// for that write is the second consent layer.
|
|
188
|
+
rules.push(`Bash(${launcherPrefix} bridge-permissions --check --json)`);
|
|
189
|
+
rules.push("Bash(pmset -g log)");
|
|
190
|
+
rules.push("Bash(git ls-tree *)");
|
|
191
|
+
rules.push("Bash(gh auth status)");
|
|
192
|
+
// Stage 0's local checks, exactly as the posture spells them.
|
|
193
|
+
rules.push("Bash(source .venv/bin/activate)");
|
|
194
|
+
rules.push("Bash(ENVIRONMENT=TEST python scripts/conductor_health_check.py --json)");
|
|
195
|
+
rules.push("Bash(alembic -c alembic.ini heads)");
|
|
196
|
+
rules.push("Bash(alembic -c alembic.ini current)");
|
|
197
|
+
// The private tally directory: create, restrict, read, and append.
|
|
198
|
+
rules.push(`Bash(mkdir -p ${BRIDGE_RUN_STATE_DIR}/*)`);
|
|
199
|
+
rules.push(`Bash(chmod 700 ${BRIDGE_RUN_STATE_DIR}/*)`);
|
|
200
|
+
rules.push(`Bash(chmod 600 ${BRIDGE_RUN_STATE_DIR}/*)`);
|
|
201
|
+
rules.push(`Read(${BRIDGE_RUN_STATE_DIR}/**)`);
|
|
202
|
+
rules.push(`Edit(${BRIDGE_RUN_STATE_DIR}/**)`);
|
|
203
|
+
rules.push(`Write(${BRIDGE_RUN_STATE_DIR}/**)`);
|
|
204
|
+
// BAPI-1399: the value-free prerequisite presence check, exactly as prescribed.
|
|
205
|
+
// Only a validated variable name fills the `*`; the form prints present or missing.
|
|
206
|
+
rules.push('Bash([ -n "${*:+x}" ] && echo present || echo missing)');
|
|
207
|
+
// Claude Code checks each `&&` / `||` segment of a compound command on its
|
|
208
|
+
// own, so each segment of the prescribed form has its own exact rule. A
|
|
209
|
+
// segment outside these three (for example one that prints a value) matches
|
|
210
|
+
// no rule.
|
|
211
|
+
rules.push('Bash([ -n "${*:+x}" ])');
|
|
212
|
+
rules.push("Bash(echo present)");
|
|
213
|
+
rules.push("Bash(echo missing)");
|
|
214
|
+
// BAPI-1399: reading `.bridge/epic-workarounds/` at the fetched epic head, and
|
|
215
|
+
// the marker-checked hand-off comment on the run's own integration PR. The
|
|
216
|
+
// `workarounds.md` write is covered by the run-state Write rule above.
|
|
217
|
+
rules.push("Bash(git fetch origin 'epic/*')");
|
|
218
|
+
rules.push("Bash(git rev-parse 'origin/epic/*')");
|
|
219
|
+
rules.push("Bash(git show '*:.bridge/epic-workarounds/*.md')");
|
|
220
|
+
rules.push("Bash(gh pr view 'epic/*' --json number,isDraft,state,headRefOid,baseRefName,comments)");
|
|
221
|
+
rules.push(`Bash(gh pr comment * --body-file ${BRIDGE_RUN_STATE_DIR}/*/workarounds.md)`);
|
|
222
|
+
// The notes-log commit on docs/<KEY>-conductor-run-notes.
|
|
223
|
+
rules.push("Bash(git switch -c 'docs/*-conductor-run-notes')");
|
|
224
|
+
rules.push("Bash(git add docs/epic-implementer/run-notes/)");
|
|
225
|
+
rules.push("Bash(git commit -m * -- docs/epic-implementer/run-notes/)");
|
|
226
|
+
// The Bridge MCP tools the supervisor uses, under each installed server key.
|
|
227
|
+
for (const serverName of serverNames) {
|
|
228
|
+
for (const tool of BRIDGE_SUPERVISOR_MCP_TOOLS) {
|
|
229
|
+
rules.push(`mcp__${serverName}__${tool}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return rules;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The user-settings `autoMode` prose. It describes the run sandbox — the run's
|
|
236
|
+
* branches and worktrees, the Bridge server, and third-party sandbox accounts the
|
|
237
|
+
* project explicitly names — so the classifier treats the supervisor's in-scope
|
|
238
|
+
* work as routine. `sandboxAccounts` adds named accounts when a caller knows them;
|
|
239
|
+
* none is ever inferred.
|
|
240
|
+
*/
|
|
241
|
+
export function buildUserAutoModeEntries(sandboxAccounts = []) {
|
|
242
|
+
const named = sandboxAccounts.map((a) => a.trim()).filter((a) => a.length > 0);
|
|
243
|
+
const accounts = named.length > 0
|
|
244
|
+
? `the third-party sandbox accounts the project names (${named.join(", ")})`
|
|
245
|
+
: "any third-party sandbox account the project explicitly names as a sandbox in its configuration or documentation";
|
|
246
|
+
return {
|
|
247
|
+
environment: [
|
|
248
|
+
"Bridge Epic Implementer runs (/drive-epic-run): a run's sandbox is its own branches " +
|
|
249
|
+
"(epic/<KEY> and each child ticket's branch_name) and the worktrees made for them, " +
|
|
250
|
+
`the Bridge server, and ${accounts}.`,
|
|
251
|
+
],
|
|
252
|
+
allow: [
|
|
253
|
+
"Bridge /drive-epic-run supervisor: the packaged Bridge CLI's sanctioned " +
|
|
254
|
+
"epic-implementer recovery verbs, drive-epic preview and attach, and readiness, applied " +
|
|
255
|
+
"to the run's own sandbox; reads, waits, the private tally under " +
|
|
256
|
+
`${BRIDGE_RUN_STATE_DIR}/, and Bridge bug reports.`,
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/** Absolute path of the project's local (gitignored) Claude settings file. */
|
|
261
|
+
export function projectSettingsPath(cwd) {
|
|
262
|
+
return path.join(cwd, ...BRIDGE_PROJECT_SETTINGS_RELPATH.split("/"));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The MCP registration keys the tool rules must be written under, read from the
|
|
266
|
+
* project's `.mcp.json`. A canonical or legacy registration yields its own key;
|
|
267
|
+
* a conflict (both keys present) yields both, so whichever one Claude Code loads
|
|
268
|
+
* is covered. A missing, malformed, or Bridge-less file yields the canonical key,
|
|
269
|
+
* because that is the key a fresh install writes.
|
|
270
|
+
*/
|
|
271
|
+
export async function resolveSupervisorServerNames(cwd, deps) {
|
|
272
|
+
let servers;
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(await deps.readFile(path.join(cwd, ".mcp.json")));
|
|
275
|
+
servers =
|
|
276
|
+
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
277
|
+
? parsed.mcpServers
|
|
278
|
+
: undefined;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
servers = undefined;
|
|
282
|
+
}
|
|
283
|
+
const resolution = resolveRegistrationKey(servers);
|
|
284
|
+
if (resolution.state === "canonical" || resolution.state === "legacy")
|
|
285
|
+
return [resolution.key];
|
|
286
|
+
if (resolution.state === "conflict")
|
|
287
|
+
return [...resolution.keys];
|
|
288
|
+
return [MCP_SERVER_NAME];
|
|
289
|
+
}
|
|
290
|
+
/** Absolute path of the user's Claude settings file. */
|
|
291
|
+
export function userSettingsPath(homeDir) {
|
|
292
|
+
return path.join(homeDir, ".claude", "settings.json");
|
|
293
|
+
}
|
|
294
|
+
/** Absolute path of Bridge's permission-ownership manifest. */
|
|
295
|
+
export function bridgePermissionsManifestPath(cwd) {
|
|
296
|
+
return path.join(cwd, ...BRIDGE_PERMISSIONS_MANIFEST_RELPATH.split("/"));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Read a settings file: missing is `{}`-equivalent, malformed is never used. Only
|
|
300
|
+
* `ENOENT` means missing. Any other read error (`EACCES` included) is refused like
|
|
301
|
+
* malformed JSON, so no writer replaces a file it could not read.
|
|
302
|
+
*/
|
|
303
|
+
export async function readSettingsFile(filePath, deps) {
|
|
304
|
+
let raw;
|
|
305
|
+
try {
|
|
306
|
+
raw = await deps.readFile(filePath);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
const code = error?.code;
|
|
310
|
+
return code === "ENOENT" ? { status: "missing" } : { status: "malformed" };
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const parsed = JSON.parse(raw);
|
|
314
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
315
|
+
return { status: "malformed" };
|
|
316
|
+
}
|
|
317
|
+
return { status: "valid", settings: parsed, raw };
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return { status: "malformed" };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function serializeSettings(settings) {
|
|
324
|
+
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
325
|
+
}
|
|
326
|
+
function emptyManifest() {
|
|
327
|
+
return {
|
|
328
|
+
version: BRIDGE_PERMISSIONS_MANIFEST_VERSION,
|
|
329
|
+
launcher: null,
|
|
330
|
+
project: [],
|
|
331
|
+
userAllow: [],
|
|
332
|
+
userEnvironment: [],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function stringList(value) {
|
|
336
|
+
if (!Array.isArray(value))
|
|
337
|
+
return null;
|
|
338
|
+
return value.every((v) => typeof v === "string") ? value : null;
|
|
339
|
+
}
|
|
340
|
+
/** Read and validate the manifest. An invalid manifest owns nothing. */
|
|
341
|
+
export async function readBridgePermissionsManifest(cwd, deps) {
|
|
342
|
+
let raw;
|
|
343
|
+
try {
|
|
344
|
+
raw = await deps.readFile(bridgePermissionsManifestPath(cwd));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return { status: "missing" };
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const doc = JSON.parse(raw);
|
|
351
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc))
|
|
352
|
+
return { status: "invalid" };
|
|
353
|
+
if (doc.version !== BRIDGE_PERMISSIONS_MANIFEST_VERSION)
|
|
354
|
+
return { status: "invalid" };
|
|
355
|
+
const project = stringList(doc.project);
|
|
356
|
+
const userAllow = stringList(doc.userAllow);
|
|
357
|
+
const userEnvironment = stringList(doc.userEnvironment);
|
|
358
|
+
const launcher = doc.launcher;
|
|
359
|
+
if (!project || !userAllow || !userEnvironment)
|
|
360
|
+
return { status: "invalid" };
|
|
361
|
+
if (launcher !== null && typeof launcher !== "string")
|
|
362
|
+
return { status: "invalid" };
|
|
363
|
+
const manifest = {
|
|
364
|
+
version: BRIDGE_PERMISSIONS_MANIFEST_VERSION,
|
|
365
|
+
launcher,
|
|
366
|
+
project,
|
|
367
|
+
userAllow,
|
|
368
|
+
userEnvironment,
|
|
369
|
+
};
|
|
370
|
+
if (doc.hooks !== undefined) {
|
|
371
|
+
const hooks = doc.hooks;
|
|
372
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks))
|
|
373
|
+
return { status: "invalid" };
|
|
374
|
+
const sessionStart = stringList(hooks[SESSION_START_HOOK_EVENT]);
|
|
375
|
+
if (!sessionStart)
|
|
376
|
+
return { status: "invalid" };
|
|
377
|
+
manifest.hooks = { SessionStart: sessionStart };
|
|
378
|
+
}
|
|
379
|
+
return { status: "valid", manifest };
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return { status: "invalid" };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Serialize the manifest with a stable key order. */
|
|
386
|
+
export function serializeBridgePermissionsManifest(manifest) {
|
|
387
|
+
const doc = {
|
|
388
|
+
version: manifest.version,
|
|
389
|
+
launcher: manifest.launcher,
|
|
390
|
+
project: manifest.project,
|
|
391
|
+
userAllow: manifest.userAllow,
|
|
392
|
+
userEnvironment: manifest.userEnvironment,
|
|
393
|
+
};
|
|
394
|
+
// Written last and only when something is owned, so a manifest without hooks
|
|
395
|
+
// serializes byte-for-byte as it did before hooks existed.
|
|
396
|
+
if (manifest.hooks && manifest.hooks.SessionStart.length > 0) {
|
|
397
|
+
doc.hooks = { SessionStart: manifest.hooks.SessionStart };
|
|
398
|
+
}
|
|
399
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
400
|
+
}
|
|
401
|
+
async function writeManifestIfChanged(cwd, next, deps) {
|
|
402
|
+
const target = bridgePermissionsManifestPath(cwd);
|
|
403
|
+
const text = serializeBridgePermissionsManifest(next);
|
|
404
|
+
let current = null;
|
|
405
|
+
try {
|
|
406
|
+
current = await deps.readFile(target);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
current = null;
|
|
410
|
+
}
|
|
411
|
+
if (current === text)
|
|
412
|
+
return true;
|
|
413
|
+
try {
|
|
414
|
+
await deps.mkdir(path.dirname(target), { recursive: true });
|
|
415
|
+
await deps.writeFile(target, text);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async function writeSettings(filePath, settings, deps) {
|
|
423
|
+
try {
|
|
424
|
+
await deps.mkdir(path.dirname(filePath), { recursive: true });
|
|
425
|
+
await deps.writeFile(filePath, serializeSettings(settings));
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// List helpers
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
/** The `permissions.allow` list, `[]` when absent, or null when not a string list. */
|
|
436
|
+
function projectAllowList(settings) {
|
|
437
|
+
const permissions = settings.permissions;
|
|
438
|
+
if (permissions === undefined)
|
|
439
|
+
return [];
|
|
440
|
+
if (permissions === null || typeof permissions !== "object" || Array.isArray(permissions))
|
|
441
|
+
return null;
|
|
442
|
+
const allow = permissions.allow;
|
|
443
|
+
if (allow === undefined)
|
|
444
|
+
return [];
|
|
445
|
+
return stringList(allow);
|
|
446
|
+
}
|
|
447
|
+
function withProjectAllowList(settings, allow) {
|
|
448
|
+
const permissions = settings.permissions && typeof settings.permissions === "object"
|
|
449
|
+
? settings.permissions
|
|
450
|
+
: {};
|
|
451
|
+
return { ...settings, permissions: { ...permissions, allow } };
|
|
452
|
+
}
|
|
453
|
+
/** An `autoMode` list, `undefined` when absent, or null when not a string list. */
|
|
454
|
+
function autoModeList(settings, key) {
|
|
455
|
+
const autoMode = settings.autoMode;
|
|
456
|
+
if (autoMode === undefined)
|
|
457
|
+
return undefined;
|
|
458
|
+
if (autoMode === null || typeof autoMode !== "object" || Array.isArray(autoMode))
|
|
459
|
+
return null;
|
|
460
|
+
const list = autoMode[key];
|
|
461
|
+
if (list === undefined)
|
|
462
|
+
return undefined;
|
|
463
|
+
return stringList(list);
|
|
464
|
+
}
|
|
465
|
+
function withAutoModeLists(settings, lists) {
|
|
466
|
+
const autoMode = settings.autoMode && typeof settings.autoMode === "object"
|
|
467
|
+
? settings.autoMode
|
|
468
|
+
: {};
|
|
469
|
+
return { ...settings, autoMode: { ...autoMode, ...lists } };
|
|
470
|
+
}
|
|
471
|
+
function unique(values) {
|
|
472
|
+
return [...new Set(values)];
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Build the SessionStart hook command for a resolved launcher and a validated pin.
|
|
476
|
+
* The default launcher is pinned by appending `@<pin>` to its package token; a
|
|
477
|
+
* valid `BAPI_MCP_CLI` override already names its own build and gets the exact
|
|
478
|
+
* hook suffix appended unchanged.
|
|
479
|
+
*/
|
|
480
|
+
export function buildSessionStartHookCommand(launcher, pinnedVersion) {
|
|
481
|
+
const prefix = launcher.source === "default" ? `${launcher.prefix}@${pinnedVersion}` : launcher.prefix;
|
|
482
|
+
return `${prefix} ${SESSION_START_HOOK_SUFFIX}`;
|
|
483
|
+
}
|
|
484
|
+
/** Every command leaf registered for an event, in order. */
|
|
485
|
+
function sessionStartCommands(settings) {
|
|
486
|
+
const hooks = settings.hooks;
|
|
487
|
+
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks))
|
|
488
|
+
return [];
|
|
489
|
+
const entries = hooks[SESSION_START_HOOK_EVENT];
|
|
490
|
+
if (!Array.isArray(entries))
|
|
491
|
+
return [];
|
|
492
|
+
const commands = [];
|
|
493
|
+
for (const entry of entries) {
|
|
494
|
+
const leaves = entry?.hooks;
|
|
495
|
+
if (!Array.isArray(leaves))
|
|
496
|
+
continue;
|
|
497
|
+
for (const leaf of leaves) {
|
|
498
|
+
const found = leaf;
|
|
499
|
+
if (found && found.type === "command" && typeof found.command === "string") {
|
|
500
|
+
commands.push(found.command);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return commands;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* True when `command` is registered exactly once, in an entry that carries the
|
|
508
|
+
* startup matcher, on a leaf that carries the five-second timeout.
|
|
509
|
+
*/
|
|
510
|
+
function isHookConfiguredCorrectly(settings, command) {
|
|
511
|
+
const hooks = settings.hooks;
|
|
512
|
+
const entries = hooks[SESSION_START_HOOK_EVENT];
|
|
513
|
+
let found = 0;
|
|
514
|
+
let correct = false;
|
|
515
|
+
for (const entry of entries) {
|
|
516
|
+
const leaves = entry?.hooks;
|
|
517
|
+
if (!Array.isArray(leaves))
|
|
518
|
+
continue;
|
|
519
|
+
for (const leaf of leaves) {
|
|
520
|
+
const candidate = leaf;
|
|
521
|
+
if (candidate && candidate.type === "command" && candidate.command === command) {
|
|
522
|
+
found += 1;
|
|
523
|
+
correct =
|
|
524
|
+
entry.matcher === SESSION_START_HOOK_MATCHER &&
|
|
525
|
+
candidate.timeout === SESSION_START_HOOK_TIMEOUT_SECONDS;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return found === 1 && correct;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Is an UNRECORDED command narrowly recognizable as Bridge's own hook? Only on
|
|
533
|
+
* install or upgrade, and only when the whole string is the default exact-pinned
|
|
534
|
+
* launcher or the current override prefix, followed by the exact hook suffix.
|
|
535
|
+
* `@latest`, an unpinned launcher, extra flags, another executable, and another
|
|
536
|
+
* package path are all NOT recognized.
|
|
537
|
+
*/
|
|
538
|
+
function isRecognizedBridgeHookCommand(command, launcher) {
|
|
539
|
+
if (launcher.source === "override" && command === `${launcher.prefix} ${SESSION_START_HOOK_SUFFIX}`) {
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
const lead = `${BRIDGE_DEFAULT_LAUNCHER}@`;
|
|
543
|
+
const tail = ` ${SESSION_START_HOOK_SUFFIX}`;
|
|
544
|
+
if (!command.startsWith(lead) || !command.endsWith(tail))
|
|
545
|
+
return false;
|
|
546
|
+
return isPlainReleaseVersion(command.slice(lead.length, command.length - tail.length));
|
|
547
|
+
}
|
|
548
|
+
/** A command that looks like a Bridge hook without being recognized as one. */
|
|
549
|
+
function looksLikeBridgeHook(command) {
|
|
550
|
+
return command.includes("check-update") && command.includes("claude-session-start");
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Compute the settings that leave exactly one correctly configured Bridge hook,
|
|
554
|
+
* in memory. Owned commands are the manifest-recorded ones plus, on install or
|
|
555
|
+
* upgrade only, narrowly recognized unrecorded ones. Every owned command is
|
|
556
|
+
* removed before the desired one is merged, so a changed pin is replaced rather
|
|
557
|
+
* than duplicated; commands that are not owned are never touched.
|
|
558
|
+
*/
|
|
559
|
+
function planSessionStartHook(settings, command, recorded, launcher) {
|
|
560
|
+
// A present-but-wrong-typed `hooks` or `SessionStart` is refused, never replaced.
|
|
561
|
+
if (inspectClaudeHookContainers(settings, [SESSION_START_HOOK_EVENT]) === "malformed") {
|
|
562
|
+
return "malformed";
|
|
563
|
+
}
|
|
564
|
+
const present = sessionStartCommands(settings);
|
|
565
|
+
const recordedSet = new Set(recorded);
|
|
566
|
+
const owned = unique(present.filter((c) => recordedSet.has(c) || isRecognizedBridgeHookCommand(c, launcher)));
|
|
567
|
+
const unrecognized = present.filter((c) => !owned.includes(c) && looksLikeBridgeHook(c)).length;
|
|
568
|
+
const others = owned.filter((c) => c !== command);
|
|
569
|
+
if (others.length === 0 && present.includes(command) && isHookConfiguredCorrectly(settings, command)) {
|
|
570
|
+
return { settings, changed: false, command, replaced: 0, unrecognized };
|
|
571
|
+
}
|
|
572
|
+
let working = settings;
|
|
573
|
+
for (const stale of owned) {
|
|
574
|
+
working = removeClaudeSettingsCommandHook(working, SESSION_START_HOOK_EVENT, stale);
|
|
575
|
+
}
|
|
576
|
+
const rebuilt = mergeClaudeSettingsWithCommandHookChecked(working, command, [SESSION_START_HOOK_EVENT], {
|
|
577
|
+
eventMatchers: { [SESSION_START_HOOK_EVENT]: SESSION_START_HOOK_MATCHER },
|
|
578
|
+
eventTimeouts: { [SESSION_START_HOOK_EVENT]: SESSION_START_HOOK_TIMEOUT_SECONDS },
|
|
579
|
+
});
|
|
580
|
+
if (!rebuilt.ok)
|
|
581
|
+
return "malformed";
|
|
582
|
+
return { settings: rebuilt.settings, changed: true, command, replaced: others.length, unrecognized };
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Write the project `permissions.allow` catalog for the resolved launcher, and
|
|
586
|
+
* optionally the pinned SessionStart hook.
|
|
587
|
+
*
|
|
588
|
+
* Only missing strings are appended; every other entry and key is kept. Strings
|
|
589
|
+
* Bridge recorded earlier that are no longer desired (for example after the
|
|
590
|
+
* launcher changed) are removed. The manifest gains exactly the strings this run
|
|
591
|
+
* inserted. Nothing is written when nothing changes.
|
|
592
|
+
*
|
|
593
|
+
* Permissions and the hook are applied to ONE settings snapshot and written once,
|
|
594
|
+
* and the manifest is written only after the settings write succeeds. A scope that
|
|
595
|
+
* cannot be changed safely (malformed structure, failed write) keeps its prior
|
|
596
|
+
* ownership record, and a settings success followed by a manifest failure is
|
|
597
|
+
* reported as a `partial` hook outcome rather than as full provisioning.
|
|
598
|
+
*/
|
|
599
|
+
export async function applyProjectPermissions(params, deps) {
|
|
600
|
+
const launcher = resolvePermissionLauncher(params.env);
|
|
601
|
+
const request = params.sessionStartHook;
|
|
602
|
+
if (!launcher.ok) {
|
|
603
|
+
return {
|
|
604
|
+
launcher,
|
|
605
|
+
project: { status: "launcher-refused", skipped: [] },
|
|
606
|
+
manifestRecorded: false,
|
|
607
|
+
...(request ? { hook: { status: "refused", reason: launcher.reason } } : {}),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
const desired = buildProjectPermissionRules(launcher.prefix, await resolveSupervisorServerNames(params.cwd, deps));
|
|
611
|
+
const settingsPath = projectSettingsPath(params.cwd);
|
|
612
|
+
const manifestRead = await readBridgePermissionsManifest(params.cwd, deps);
|
|
613
|
+
const manifest = manifestRead.status === "valid" ? manifestRead.manifest : emptyManifest();
|
|
614
|
+
const read = await readSettingsFile(settingsPath, deps);
|
|
615
|
+
if (read.status === "malformed") {
|
|
616
|
+
return {
|
|
617
|
+
launcher,
|
|
618
|
+
project: { status: "malformed", path: settingsPath, skipped: desired },
|
|
619
|
+
manifestRecorded: false,
|
|
620
|
+
...(request ? { hook: invalidPinOr(request, { status: "malformed", path: settingsPath }) } : {}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const current = read.status === "valid" ? read.settings : {};
|
|
624
|
+
let working = current;
|
|
625
|
+
// ---- Permission rules ----
|
|
626
|
+
const allow = projectAllowList(current);
|
|
627
|
+
let project;
|
|
628
|
+
let permissionsChanged = false;
|
|
629
|
+
let permissionsApplied = false;
|
|
630
|
+
let inserted = [];
|
|
631
|
+
let ownedStill = manifest.project;
|
|
632
|
+
if (allow === null) {
|
|
633
|
+
project = { status: "malformed", path: settingsPath, skipped: desired };
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
const desiredSet = new Set(desired);
|
|
637
|
+
const stale = manifest.project.filter((rule) => !desiredSet.has(rule));
|
|
638
|
+
const staleSet = new Set(stale);
|
|
639
|
+
const removedStale = allow.filter((rule) => staleSet.has(rule));
|
|
640
|
+
const kept = allow.filter((rule) => !staleSet.has(rule));
|
|
641
|
+
const keptSet = new Set(kept);
|
|
642
|
+
inserted = desired.filter((rule) => !keptSet.has(rule));
|
|
643
|
+
const alreadyPresent = desired.filter((rule) => keptSet.has(rule));
|
|
644
|
+
if (inserted.length > 0 || removedStale.length > 0) {
|
|
645
|
+
working = withProjectAllowList(working, [...kept, ...inserted]);
|
|
646
|
+
permissionsChanged = true;
|
|
647
|
+
}
|
|
648
|
+
ownedStill = manifest.project.filter((rule) => desiredSet.has(rule));
|
|
649
|
+
permissionsApplied = true;
|
|
650
|
+
project = {
|
|
651
|
+
status: "unchanged",
|
|
652
|
+
path: settingsPath,
|
|
653
|
+
inserted,
|
|
654
|
+
alreadyPresent,
|
|
655
|
+
removedStale,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
// ---- SessionStart hook ----
|
|
659
|
+
let hookPlan = null;
|
|
660
|
+
let hook;
|
|
661
|
+
if (request) {
|
|
662
|
+
if (!isPlainReleaseVersion(request.pinnedVersion)) {
|
|
663
|
+
hook = { status: "skipped", reason: "invalid-pin" };
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
const command = buildSessionStartHookCommand(launcher, request.pinnedVersion);
|
|
667
|
+
const planned = planSessionStartHook(working, command, manifest.hooks?.SessionStart ?? [], launcher);
|
|
668
|
+
if (planned === "malformed") {
|
|
669
|
+
hook = { status: "malformed", path: settingsPath };
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
hookPlan = planned;
|
|
673
|
+
working = planned.settings;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const hookChanged = hookPlan?.changed === true;
|
|
678
|
+
// ---- One settings write ----
|
|
679
|
+
if (permissionsChanged || hookChanged) {
|
|
680
|
+
const ok = await writeSettings(settingsPath, working, deps);
|
|
681
|
+
if (!ok) {
|
|
682
|
+
return {
|
|
683
|
+
launcher,
|
|
684
|
+
project: permissionsApplied
|
|
685
|
+
? { status: "io-error", path: settingsPath, skipped: inserted }
|
|
686
|
+
: project,
|
|
687
|
+
manifestRecorded: false,
|
|
688
|
+
...(request ? { hook: hookPlan ? { status: "io-error", path: settingsPath } : hook } : {}),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
if (project.status === "unchanged" && permissionsChanged)
|
|
692
|
+
project = { ...project, status: "written" };
|
|
693
|
+
}
|
|
694
|
+
// ---- One manifest write, after the settings are durable ----
|
|
695
|
+
const nextManifest = { ...manifest };
|
|
696
|
+
if (permissionsApplied) {
|
|
697
|
+
nextManifest.launcher = launcher.prefix;
|
|
698
|
+
nextManifest.project = unique([...ownedStill, ...inserted]);
|
|
699
|
+
}
|
|
700
|
+
if (hookPlan)
|
|
701
|
+
nextManifest.hooks = { SessionStart: [hookPlan.command] };
|
|
702
|
+
const needsManifest = permissionsApplied || hookPlan !== null;
|
|
703
|
+
const manifestRecorded = needsManifest ? await writeManifestIfChanged(params.cwd, nextManifest, deps) : false;
|
|
704
|
+
if (hookPlan) {
|
|
705
|
+
const base = {
|
|
706
|
+
path: settingsPath,
|
|
707
|
+
command: hookPlan.command,
|
|
708
|
+
replaced: hookPlan.replaced,
|
|
709
|
+
unrecognized: hookPlan.unrecognized,
|
|
710
|
+
};
|
|
711
|
+
hook = manifestRecorded
|
|
712
|
+
? { status: hookChanged ? "written" : "unchanged", ...base }
|
|
713
|
+
: { status: "partial", ...base };
|
|
714
|
+
}
|
|
715
|
+
return { launcher, project, manifestRecorded, ...(request && hook ? { hook } : {}) };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Plain-language report lines for a hook outcome, shared by install and upgrade so
|
|
719
|
+
* the two cannot drift. `lines` are progress; `warnings` need attention but never
|
|
720
|
+
* change a primary exit code. Nothing here echoes a similar-looking user command:
|
|
721
|
+
* unrecognized candidates are counted only.
|
|
722
|
+
*/
|
|
723
|
+
export function describeSessionStartHookOutcome(hook) {
|
|
724
|
+
const lines = [];
|
|
725
|
+
const warnings = [];
|
|
726
|
+
switch (hook.status) {
|
|
727
|
+
case "written":
|
|
728
|
+
case "unchanged":
|
|
729
|
+
case "partial": {
|
|
730
|
+
if (hook.status === "written") {
|
|
731
|
+
lines.push(`SessionStart upgrade-notice hook (Claude Code only, per developer) in ${hook.path}:`);
|
|
732
|
+
lines.push(` wrote ${hook.command}`);
|
|
733
|
+
if (hook.replaced > 0) {
|
|
734
|
+
lines.push(` replaced ${hook.replaced} earlier Bridge hook command(s) with this one`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else if (hook.status === "unchanged") {
|
|
738
|
+
lines.push(`SessionStart upgrade-notice hook already present in ${hook.path}.`);
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
warnings.push(`the SessionStart upgrade-notice hook was written to ${hook.path}, but ` +
|
|
742
|
+
`${BRIDGE_PERMISSIONS_MANIFEST_RELPATH} could not be updated; doctor and removal may not see it`);
|
|
743
|
+
}
|
|
744
|
+
if (hook.unrecognized > 0) {
|
|
745
|
+
warnings.push(`${hook.unrecognized} existing SessionStart command(s) resemble Bridge's hook but were not ` +
|
|
746
|
+
"recognized as Bridge's, so they were left in place");
|
|
747
|
+
}
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case "skipped":
|
|
751
|
+
warnings.push("the SessionStart upgrade-notice hook was not written: no valid pinned Bridge version could be " +
|
|
752
|
+
"determined for this project");
|
|
753
|
+
break;
|
|
754
|
+
case "refused":
|
|
755
|
+
warnings.push(`the SessionStart upgrade-notice hook was not written: ${hook.reason}`);
|
|
756
|
+
break;
|
|
757
|
+
case "malformed":
|
|
758
|
+
warnings.push(`${hook.path} has a malformed hooks structure and was left unchanged; ` +
|
|
759
|
+
"the SessionStart upgrade-notice hook was NOT written");
|
|
760
|
+
break;
|
|
761
|
+
case "io-error":
|
|
762
|
+
warnings.push(`${hook.path} could not be written; the SessionStart upgrade-notice hook was NOT written`);
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
return { lines, warnings };
|
|
766
|
+
}
|
|
767
|
+
/** A malformed settings file cannot take the hook, but an invalid pin is reported first. */
|
|
768
|
+
function invalidPinOr(request, fallback) {
|
|
769
|
+
return isPlainReleaseVersion(request.pinnedVersion)
|
|
770
|
+
? fallback
|
|
771
|
+
: { status: "skipped", reason: "invalid-pin" };
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Write Bridge's `autoMode.allow` and `autoMode.environment` prose into the
|
|
775
|
+
* user's settings. Call ONLY after the user consented. A list Bridge creates
|
|
776
|
+
* starts with `"$defaults"` so Claude Code's built-in entries are kept; an
|
|
777
|
+
* existing list keeps every entry it had, `"$defaults"` included, except an
|
|
778
|
+
* entry this project's manifest recorded as Bridge's own that Bridge no longer
|
|
779
|
+
* wants (its wording changed): that one is replaced, so an upgrade never leaves
|
|
780
|
+
* stale Bridge prose beside the new text. Entries Bridge did not record are
|
|
781
|
+
* never touched.
|
|
782
|
+
*/
|
|
783
|
+
export async function applyUserAutoModeEntries(params, deps) {
|
|
784
|
+
const entries = buildUserAutoModeEntries(params.sandboxAccounts);
|
|
785
|
+
const settingsPath = userSettingsPath(params.homeDir);
|
|
786
|
+
const proposed = [...entries.allow, ...entries.environment];
|
|
787
|
+
const read = await readSettingsFile(settingsPath, deps);
|
|
788
|
+
if (read.status === "malformed") {
|
|
789
|
+
return { user: { status: "malformed", path: settingsPath, skipped: proposed }, manifestRecorded: false };
|
|
790
|
+
}
|
|
791
|
+
const current = read.status === "valid" ? read.settings : {};
|
|
792
|
+
const allowList = autoModeList(current, "allow");
|
|
793
|
+
const environmentList = autoModeList(current, "environment");
|
|
794
|
+
if (allowList === null || environmentList === null) {
|
|
795
|
+
return { user: { status: "malformed", path: settingsPath, skipped: proposed }, manifestRecorded: false };
|
|
796
|
+
}
|
|
797
|
+
const manifestRead = await readBridgePermissionsManifest(params.cwd, deps);
|
|
798
|
+
const manifest = manifestRead.status === "valid" ? manifestRead.manifest : emptyManifest();
|
|
799
|
+
const merge = (existing, wanted, recorded) => {
|
|
800
|
+
const wantedSet = new Set(wanted);
|
|
801
|
+
const staleSet = new Set(recorded.filter((entry) => !wantedSet.has(entry)));
|
|
802
|
+
const prior = existing ?? [AUTO_MODE_DEFAULTS_SENTINEL];
|
|
803
|
+
const removed = prior.filter((entry) => staleSet.has(entry));
|
|
804
|
+
const base = prior.filter((entry) => !staleSet.has(entry));
|
|
805
|
+
const have = new Set(base);
|
|
806
|
+
const added = wanted.filter((entry) => !have.has(entry));
|
|
807
|
+
const present = wanted.filter((entry) => have.has(entry));
|
|
808
|
+
const owned = recorded.filter((entry) => wantedSet.has(entry));
|
|
809
|
+
return { next: [...base, ...added], added, present, removed, owned, created: existing === undefined };
|
|
810
|
+
};
|
|
811
|
+
const allowMerge = merge(allowList, entries.allow, manifest.userAllow);
|
|
812
|
+
const environmentMerge = merge(environmentList, entries.environment, manifest.userEnvironment);
|
|
813
|
+
const inserted = [...allowMerge.added, ...environmentMerge.added];
|
|
814
|
+
const alreadyPresent = [...allowMerge.present, ...environmentMerge.present];
|
|
815
|
+
const removedStale = [...allowMerge.removed, ...environmentMerge.removed];
|
|
816
|
+
let status = "unchanged";
|
|
817
|
+
if (inserted.length > 0 || removedStale.length > 0) {
|
|
818
|
+
const next = withAutoModeLists(current, { allow: allowMerge.next, environment: environmentMerge.next });
|
|
819
|
+
const ok = await writeSettings(settingsPath, next, deps);
|
|
820
|
+
if (!ok) {
|
|
821
|
+
return { user: { status: "io-error", path: settingsPath, skipped: inserted }, manifestRecorded: false };
|
|
822
|
+
}
|
|
823
|
+
status = "written";
|
|
824
|
+
}
|
|
825
|
+
const manifestRecorded = await writeManifestIfChanged(params.cwd, {
|
|
826
|
+
...manifest,
|
|
827
|
+
userAllow: unique([...allowMerge.owned, ...allowMerge.added]),
|
|
828
|
+
userEnvironment: unique([...environmentMerge.owned, ...environmentMerge.added]),
|
|
829
|
+
}, deps);
|
|
830
|
+
return {
|
|
831
|
+
user: { status, path: settingsPath, inserted, alreadyPresent, removedStale },
|
|
832
|
+
manifestRecorded,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Inspect both scopes without writing anything. Project rules are `present`
|
|
837
|
+
* when every desired rule is in the allow list, `missing` when any is absent,
|
|
838
|
+
* and `drifted` when the manifest's launcher differs from the one resolved now
|
|
839
|
+
* or a Bridge-owned rule is no longer desired. User entries are `present` when
|
|
840
|
+
* every proposed entry is in its list and `missing` otherwise.
|
|
841
|
+
*/
|
|
842
|
+
export async function inspectBridgePermissions(params, deps) {
|
|
843
|
+
const launcher = resolvePermissionLauncher(params.env);
|
|
844
|
+
const manifestRead = await readBridgePermissionsManifest(params.cwd, deps);
|
|
845
|
+
const manifest = manifestRead.status === "valid" ? manifestRead.manifest : emptyManifest();
|
|
846
|
+
let project;
|
|
847
|
+
const projectPath = projectSettingsPath(params.cwd);
|
|
848
|
+
if (!launcher.ok) {
|
|
849
|
+
project = { state: "launcher-refused" };
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
const read = await readSettingsFile(projectPath, deps);
|
|
853
|
+
const allow = read.status === "malformed" ? null : projectAllowList(read.status === "valid" ? read.settings : {});
|
|
854
|
+
if (allow === null) {
|
|
855
|
+
project = { state: "malformed", path: projectPath };
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
const desired = buildProjectPermissionRules(launcher.prefix, await resolveSupervisorServerNames(params.cwd, deps));
|
|
859
|
+
const have = new Set(allow);
|
|
860
|
+
const missing = desired.filter((rule) => !have.has(rule));
|
|
861
|
+
const desiredSet = new Set(desired);
|
|
862
|
+
const stale = manifest.project.filter((rule) => !desiredSet.has(rule) && have.has(rule));
|
|
863
|
+
const launcherChanged = manifest.launcher !== null && manifest.launcher !== launcher.prefix;
|
|
864
|
+
if (launcherChanged || stale.length > 0) {
|
|
865
|
+
project = {
|
|
866
|
+
state: "drifted",
|
|
867
|
+
path: projectPath,
|
|
868
|
+
missing,
|
|
869
|
+
stale,
|
|
870
|
+
reason: launcherChanged
|
|
871
|
+
? "the launcher prefix changed since the rules were written"
|
|
872
|
+
: "Bridge-written rules no longer match the current catalog or MCP server key",
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
else if (missing.length > 0) {
|
|
876
|
+
project = { state: "missing", path: projectPath, missing };
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
project = { state: "present", path: projectPath, count: desired.length };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const userPath = userSettingsPath(params.homeDir);
|
|
884
|
+
const userRead = await readSettingsFile(userPath, deps);
|
|
885
|
+
let user;
|
|
886
|
+
if (userRead.status === "malformed") {
|
|
887
|
+
user = { state: "malformed", path: userPath };
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
const settings = userRead.status === "valid" ? userRead.settings : {};
|
|
891
|
+
const allowList = autoModeList(settings, "allow");
|
|
892
|
+
const environmentList = autoModeList(settings, "environment");
|
|
893
|
+
if (allowList === null || environmentList === null) {
|
|
894
|
+
user = { state: "malformed", path: userPath };
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
const entries = buildUserAutoModeEntries();
|
|
898
|
+
const haveAllow = new Set(allowList ?? []);
|
|
899
|
+
const haveEnvironment = new Set(environmentList ?? []);
|
|
900
|
+
const missing = [
|
|
901
|
+
...entries.allow.filter((e) => !haveAllow.has(e)),
|
|
902
|
+
...entries.environment.filter((e) => !haveEnvironment.has(e)),
|
|
903
|
+
];
|
|
904
|
+
user =
|
|
905
|
+
missing.length === 0
|
|
906
|
+
? { state: "present", path: userPath, count: entries.allow.length + entries.environment.length }
|
|
907
|
+
: { state: "missing", path: userPath, missing };
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return { launcher, recordedLauncher: manifest.launcher, project, user };
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Remove ONLY the strings Bridge recorded in its manifest, from both settings
|
|
914
|
+
* files. Every other entry and key stays, including a user's own identical
|
|
915
|
+
* string that predates Bridge (it was never recorded). A malformed file is never
|
|
916
|
+
* written; its recorded strings stay recorded and are reported as unremovable.
|
|
917
|
+
*/
|
|
918
|
+
export async function removeBridgePermissions(params, deps) {
|
|
919
|
+
const manifestRead = await readBridgePermissionsManifest(params.cwd, deps);
|
|
920
|
+
const manifest = manifestRead.status === "valid" ? manifestRead.manifest : emptyManifest();
|
|
921
|
+
const next = { ...manifest };
|
|
922
|
+
// Project scope: the permission rules and the SessionStart hook share ONE
|
|
923
|
+
// settings snapshot and ONE write, so neither removal can overwrite the other.
|
|
924
|
+
const projectPath = projectSettingsPath(params.cwd);
|
|
925
|
+
const recordedHooks = manifest.hooks?.SessionStart ?? [];
|
|
926
|
+
let project;
|
|
927
|
+
let hook;
|
|
928
|
+
if (manifest.project.length === 0 && recordedHooks.length === 0) {
|
|
929
|
+
project = { status: "nothing-recorded", path: projectPath, removed: [] };
|
|
930
|
+
hook = { status: "nothing-recorded", path: projectPath, removed: [] };
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
const read = await readSettingsFile(projectPath, deps);
|
|
934
|
+
if (read.status === "malformed") {
|
|
935
|
+
project =
|
|
936
|
+
manifest.project.length === 0
|
|
937
|
+
? { status: "nothing-recorded", path: projectPath, removed: [] }
|
|
938
|
+
: { status: "malformed", path: projectPath, unremovable: manifest.project };
|
|
939
|
+
hook =
|
|
940
|
+
recordedHooks.length === 0
|
|
941
|
+
? { status: "nothing-recorded", path: projectPath, removed: [] }
|
|
942
|
+
: { status: "malformed", path: projectPath, unremovable: recordedHooks };
|
|
943
|
+
}
|
|
944
|
+
else {
|
|
945
|
+
const settings = read.status === "valid" ? read.settings : {};
|
|
946
|
+
let working = settings;
|
|
947
|
+
// Rules.
|
|
948
|
+
const allow = projectAllowList(settings);
|
|
949
|
+
let rulesRemoved = [];
|
|
950
|
+
let rulesMalformed = false;
|
|
951
|
+
if (manifest.project.length > 0) {
|
|
952
|
+
if (allow === null) {
|
|
953
|
+
rulesMalformed = true;
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
const owned = new Set(manifest.project);
|
|
957
|
+
rulesRemoved = allow.filter((rule) => owned.has(rule));
|
|
958
|
+
if (rulesRemoved.length > 0) {
|
|
959
|
+
working = withProjectAllowList(working, allow.filter((rule) => !owned.has(rule)));
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// Hook: only the exact recorded commands, never a recognizable lookalike.
|
|
964
|
+
let hooksRemoved = [];
|
|
965
|
+
let hooksMalformed = false;
|
|
966
|
+
if (recordedHooks.length > 0) {
|
|
967
|
+
if (inspectClaudeHookContainers(working, [SESSION_START_HOOK_EVENT]) === "malformed") {
|
|
968
|
+
hooksMalformed = true;
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
const present = new Set(sessionStartCommands(working));
|
|
972
|
+
for (const command of recordedHooks) {
|
|
973
|
+
if (present.has(command))
|
|
974
|
+
hooksRemoved.push(command);
|
|
975
|
+
working = removeClaudeSettingsCommandHook(working, SESSION_START_HOOK_EVENT, command);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
// One write, only when something changed and the file exists.
|
|
980
|
+
let writeOk = true;
|
|
981
|
+
if ((rulesRemoved.length > 0 || hooksRemoved.length > 0) && read.status === "valid") {
|
|
982
|
+
writeOk = await writeSettings(projectPath, working, deps);
|
|
983
|
+
}
|
|
984
|
+
if (manifest.project.length === 0) {
|
|
985
|
+
project = { status: "nothing-recorded", path: projectPath, removed: [] };
|
|
986
|
+
}
|
|
987
|
+
else if (rulesMalformed) {
|
|
988
|
+
project = { status: "malformed", path: projectPath, unremovable: manifest.project };
|
|
989
|
+
}
|
|
990
|
+
else if (writeOk || rulesRemoved.length === 0) {
|
|
991
|
+
project = { status: "removed", path: projectPath, removed: rulesRemoved };
|
|
992
|
+
next.project = [];
|
|
993
|
+
next.launcher = null;
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
project = { status: "io-error", path: projectPath, unremovable: rulesRemoved };
|
|
997
|
+
}
|
|
998
|
+
if (recordedHooks.length === 0) {
|
|
999
|
+
hook = { status: "nothing-recorded", path: projectPath, removed: [] };
|
|
1000
|
+
}
|
|
1001
|
+
else if (hooksMalformed) {
|
|
1002
|
+
hook = { status: "malformed", path: projectPath, unremovable: recordedHooks };
|
|
1003
|
+
}
|
|
1004
|
+
else if (writeOk || hooksRemoved.length === 0) {
|
|
1005
|
+
// Ownership clears only once the settings write succeeded (or there was
|
|
1006
|
+
// nothing left in the file to remove).
|
|
1007
|
+
hook = { status: "removed", path: projectPath, removed: hooksRemoved };
|
|
1008
|
+
next.hooks = undefined;
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
hook = { status: "io-error", path: projectPath, unremovable: hooksRemoved };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// User scope.
|
|
1016
|
+
const userPath = userSettingsPath(params.homeDir);
|
|
1017
|
+
let user;
|
|
1018
|
+
const ownedUser = [...manifest.userAllow, ...manifest.userEnvironment];
|
|
1019
|
+
if (ownedUser.length === 0) {
|
|
1020
|
+
user = { status: "nothing-recorded", path: userPath, removed: [] };
|
|
1021
|
+
}
|
|
1022
|
+
else {
|
|
1023
|
+
const read = await readSettingsFile(userPath, deps);
|
|
1024
|
+
const settings = read.status === "valid" ? read.settings : {};
|
|
1025
|
+
const allowList = read.status === "malformed" ? null : autoModeList(settings, "allow");
|
|
1026
|
+
const environmentList = read.status === "malformed" ? null : autoModeList(settings, "environment");
|
|
1027
|
+
if (allowList === null || environmentList === null) {
|
|
1028
|
+
user = { status: "malformed", path: userPath, unremovable: ownedUser };
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
const ownedAllow = new Set(manifest.userAllow);
|
|
1032
|
+
const ownedEnvironment = new Set(manifest.userEnvironment);
|
|
1033
|
+
const removedAllow = (allowList ?? []).filter((e) => ownedAllow.has(e));
|
|
1034
|
+
const removedEnvironment = (environmentList ?? []).filter((e) => ownedEnvironment.has(e));
|
|
1035
|
+
const removed = [...removedAllow, ...removedEnvironment];
|
|
1036
|
+
let ok = true;
|
|
1037
|
+
if (removed.length > 0 && read.status === "valid") {
|
|
1038
|
+
const lists = {};
|
|
1039
|
+
if (allowList !== undefined)
|
|
1040
|
+
lists.allow = allowList.filter((e) => !ownedAllow.has(e));
|
|
1041
|
+
if (environmentList !== undefined)
|
|
1042
|
+
lists.environment = environmentList.filter((e) => !ownedEnvironment.has(e));
|
|
1043
|
+
ok = await writeSettings(userPath, withAutoModeLists(read.settings, lists), deps);
|
|
1044
|
+
}
|
|
1045
|
+
if (ok) {
|
|
1046
|
+
user = { status: "removed", path: userPath, removed };
|
|
1047
|
+
next.userAllow = [];
|
|
1048
|
+
next.userEnvironment = [];
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
user = { status: "io-error", path: userPath, unremovable: removed };
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const changed = next.project.length !== manifest.project.length ||
|
|
1056
|
+
next.userAllow.length !== manifest.userAllow.length ||
|
|
1057
|
+
next.userEnvironment.length !== manifest.userEnvironment.length ||
|
|
1058
|
+
(next.hooks?.SessionStart.length ?? 0) !== (manifest.hooks?.SessionStart.length ?? 0);
|
|
1059
|
+
const manifestUpdated = manifestRead.status === "valid" && changed ? await writeManifestIfChanged(params.cwd, next, deps) : true;
|
|
1060
|
+
return { project, hook, user, manifestUpdated };
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* The consent text for the user-settings write (BAPI-1448). The CLI's interactive
|
|
1064
|
+
* mode prints everything up to the last line and reads the answer at that line;
|
|
1065
|
+
* `/drive-epic-run` step 3a reproduces the same text byte for byte, and a test pins
|
|
1066
|
+
* the two together.
|
|
1067
|
+
*/
|
|
1068
|
+
export const USER_AUTO_MODE_CONSENT_PROMPT = "Epic runs work best in Claude Code's auto mode, where Claude acts without asking you each\n" +
|
|
1069
|
+
"time. Auto mode has a safety check that can pause routine run steps, like creating the epic's\n" +
|
|
1070
|
+
"branches or updating the run's own records, because it doesn't know they belong to the run.\n" +
|
|
1071
|
+
"\n" +
|
|
1072
|
+
"Bridge can add a short description of the run's sandbox to ~/.claude/settings.json so the\n" +
|
|
1073
|
+
"check recognizes this work. It describes:\n" +
|
|
1074
|
+
" • the run's own branches (epic/<KEY> and each child ticket's branch) and their worktrees\n" +
|
|
1075
|
+
" • the Bridge server\n" +
|
|
1076
|
+
" • any sandbox accounts your project names\n" +
|
|
1077
|
+
"It grants no other access, and you can remove it any time with --remove-bridge-permissions.\n" +
|
|
1078
|
+
"\n" +
|
|
1079
|
+
"Add it? (y/N) ";
|
|
1080
|
+
function logList(log, items) {
|
|
1081
|
+
for (const item of items)
|
|
1082
|
+
log(` ${item}`);
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* The installer's permission step. Writes the project rules and the pinned
|
|
1086
|
+
* SessionStart hook to `.claude/settings.local.json` and reports them. It never
|
|
1087
|
+
* reads, writes, or mentions the user's `~/.claude/settings.json`: that write is
|
|
1088
|
+
* the `bridge-permissions` subcommand's job (BAPI-1448). Every rule written is
|
|
1089
|
+
* listed, with the launcher prefix it covers. A malformed file is reported on
|
|
1090
|
+
* stderr and skipped; the rest of the install continues.
|
|
1091
|
+
*/
|
|
1092
|
+
export async function runBridgePermissionsInstallStep(deps) {
|
|
1093
|
+
const { log, errorLog } = deps;
|
|
1094
|
+
log("");
|
|
1095
|
+
log("Bridge permission rules for /drive-epic-run (auto mode):");
|
|
1096
|
+
if (deps.ensureGitignored) {
|
|
1097
|
+
for (const relPath of [BRIDGE_PERMISSIONS_MANIFEST_RELPATH, BRIDGE_PROJECT_SETTINGS_RELPATH]) {
|
|
1098
|
+
try {
|
|
1099
|
+
await deps.ensureGitignored(relPath);
|
|
1100
|
+
}
|
|
1101
|
+
catch {
|
|
1102
|
+
errorLog(` warning: could not add ${relPath} to .gitignore (non-fatal).`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
const project = await applyProjectPermissions({ cwd: deps.cwd, env: deps.env, sessionStartHook: deps.sessionStartHook }, deps);
|
|
1107
|
+
if (!project.launcher.ok) {
|
|
1108
|
+
errorLog(` warning: project permission rules were not written: ${project.launcher.reason}.`);
|
|
1109
|
+
errorLog(` Unset ${BRIDGE_LAUNCHER_ENV}, or set it to a plain command prefix, then re-run install.`);
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
log(` Launcher prefix covered: ${project.launcher.prefix}`);
|
|
1113
|
+
const scope = project.project;
|
|
1114
|
+
if (scope.status === "written" || scope.status === "unchanged") {
|
|
1115
|
+
log(` ${scope.path}:`);
|
|
1116
|
+
if (scope.inserted.length > 0) {
|
|
1117
|
+
log(` Wrote ${scope.inserted.length} permissions.allow rule(s):`);
|
|
1118
|
+
logList(log, scope.inserted);
|
|
1119
|
+
}
|
|
1120
|
+
if (scope.alreadyPresent.length > 0) {
|
|
1121
|
+
log(` Already present (${scope.alreadyPresent.length}):`);
|
|
1122
|
+
logList(log, scope.alreadyPresent);
|
|
1123
|
+
}
|
|
1124
|
+
if (scope.removedStale.length > 0) {
|
|
1125
|
+
log(` Removed ${scope.removedStale.length} rule(s) Bridge wrote for an earlier launcher:`);
|
|
1126
|
+
logList(log, scope.removedStale);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
else if (scope.status === "malformed" || scope.status === "io-error") {
|
|
1130
|
+
errorLog(` warning: ${scope.path} ${scope.status === "malformed" ? "is not valid JSON settings and was left unchanged" : "could not be written"}; ` +
|
|
1131
|
+
`these ${scope.skipped.length} rule(s) were NOT written:`);
|
|
1132
|
+
logList(errorLog, scope.skipped);
|
|
1133
|
+
}
|
|
1134
|
+
if (!project.manifestRecorded && (scope.status === "written" || scope.status === "unchanged")) {
|
|
1135
|
+
errorLog(` warning: could not update ${BRIDGE_PERMISSIONS_MANIFEST_RELPATH}; doctor and removal may not see these rules.`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// The hook is reported on its own: a refused launcher or a malformed hooks
|
|
1139
|
+
// structure never hides, or is hidden by, the permission-rule result above.
|
|
1140
|
+
if (project.hook) {
|
|
1141
|
+
const described = describeSessionStartHookOutcome(project.hook);
|
|
1142
|
+
for (const line of described.lines)
|
|
1143
|
+
log(` ${line}`);
|
|
1144
|
+
for (const warning of described.warnings)
|
|
1145
|
+
errorLog(` warning: ${warning}.`);
|
|
1146
|
+
}
|
|
1147
|
+
return { project };
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* The removal flag's step. Removes only Bridge-recorded strings and reports what
|
|
1151
|
+
* it removed and what it could not. Returns a process exit code: 0 when every
|
|
1152
|
+
* recorded string is gone, 1 when a malformed or unwritable file kept some.
|
|
1153
|
+
*/
|
|
1154
|
+
export async function runBridgePermissionsRemoval(deps) {
|
|
1155
|
+
const { log, errorLog } = deps;
|
|
1156
|
+
const result = await removeBridgePermissions({ cwd: deps.cwd, homeDir: deps.homeDir }, deps);
|
|
1157
|
+
let failed = false;
|
|
1158
|
+
for (const [label, scope] of [
|
|
1159
|
+
["project permissions.allow", result.project],
|
|
1160
|
+
["project SessionStart hook", result.hook],
|
|
1161
|
+
["user autoMode", result.user],
|
|
1162
|
+
]) {
|
|
1163
|
+
if (scope.status === "nothing-recorded") {
|
|
1164
|
+
// The hook is a newer scope: say nothing when it never owned anything, so an
|
|
1165
|
+
// install that never wrote one keeps its original removal output.
|
|
1166
|
+
if (label === "project SessionStart hook")
|
|
1167
|
+
continue;
|
|
1168
|
+
log(`${label}: nothing Bridge wrote is recorded (${scope.path}).`);
|
|
1169
|
+
}
|
|
1170
|
+
else if (scope.status === "removed") {
|
|
1171
|
+
log(`${label}: removed ${scope.removed.length} Bridge entr${scope.removed.length === 1 ? "y" : "ies"} from ${scope.path}.`);
|
|
1172
|
+
logList(log, scope.removed);
|
|
1173
|
+
}
|
|
1174
|
+
else if (scope.status === "malformed" || scope.status === "io-error") {
|
|
1175
|
+
failed = true;
|
|
1176
|
+
errorLog(`${label}: ${scope.path} ${scope.status === "malformed" ? "is not valid JSON settings" : "could not be written"}; ` +
|
|
1177
|
+
"these Bridge entries could not be removed:");
|
|
1178
|
+
logList(errorLog, scope.unremovable);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (!result.manifestUpdated) {
|
|
1182
|
+
failed = true;
|
|
1183
|
+
errorLog(`warning: could not update ${BRIDGE_PERMISSIONS_MANIFEST_RELPATH}.`);
|
|
1184
|
+
}
|
|
1185
|
+
log("Entries you added yourself were left in place.");
|
|
1186
|
+
return failed ? 1 : 0;
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Parse the subcommand's argv. Only no flags, `--check --json` (either order),
|
|
1190
|
+
* `--user-consented`, and `--help` are accepted; every other form — an unknown flag,
|
|
1191
|
+
* a repeated flag, `--check` without `--json`, `--check` with `--user-consented` — is
|
|
1192
|
+
* refused here, before any file is read.
|
|
1193
|
+
*/
|
|
1194
|
+
export function parseBridgePermissionsArgv(argv) {
|
|
1195
|
+
if (argv.length === 0)
|
|
1196
|
+
return { ok: true, mode: "interactive" };
|
|
1197
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h"))
|
|
1198
|
+
return { ok: true, mode: "help" };
|
|
1199
|
+
if (argv.length === 1 && argv[0] === "--user-consented")
|
|
1200
|
+
return { ok: true, mode: "user-consented" };
|
|
1201
|
+
if (argv.length === 2 && argv.includes("--check") && argv.includes("--json")) {
|
|
1202
|
+
return { ok: true, mode: "check-json" };
|
|
1203
|
+
}
|
|
1204
|
+
return { ok: false, error: `Unsupported arguments: ${BRIDGE_PERMISSIONS_USAGE_HINT}` };
|
|
1205
|
+
}
|
|
1206
|
+
const BRIDGE_PERMISSIONS_USAGE_HINT = "use no flags, `--check --json`, `--user-consented`, or `--help`.";
|
|
1207
|
+
/** Static help text for the subcommand. */
|
|
1208
|
+
export function getBridgePermissionsUsage() {
|
|
1209
|
+
return [
|
|
1210
|
+
"Usage:",
|
|
1211
|
+
` npx -y ${MCP_PACKAGE_NAME} bridge-permissions Ask, then add Bridge's auto-mode`,
|
|
1212
|
+
" sandbox description to ~/.claude/settings.json",
|
|
1213
|
+
` npx -y ${MCP_PACKAGE_NAME} bridge-permissions --check --json Read-only: print the user-scope state`,
|
|
1214
|
+
" (present, missing, or malformed) as JSON",
|
|
1215
|
+
` npx -y ${MCP_PACKAGE_NAME} bridge-permissions --user-consented`,
|
|
1216
|
+
" Write without asking. Reserved for /drive-epic-run,",
|
|
1217
|
+
" after you said yes to its question in the current",
|
|
1218
|
+
" session. Do not script it.",
|
|
1219
|
+
"",
|
|
1220
|
+
"Run from the project root: the ownership manifest is .bridge/bridge-permissions.json in the",
|
|
1221
|
+
"current directory. Without a terminal, no-flag mode writes nothing and prints what it would add.",
|
|
1222
|
+
`Remove the entries later with: npx -y ${MCP_PACKAGE_NAME} install --remove-bridge-permissions`,
|
|
1223
|
+
].join("\n");
|
|
1224
|
+
}
|
|
1225
|
+
/** Report one user-settings write: wording shared by the interactive and consented modes. */
|
|
1226
|
+
function reportUserAutoModeWrite(result, log, errorLog) {
|
|
1227
|
+
const scope = result.user;
|
|
1228
|
+
let exitCode = 0;
|
|
1229
|
+
if (scope.status === "written" || scope.status === "unchanged") {
|
|
1230
|
+
log(`${scope.path}:`);
|
|
1231
|
+
if (scope.inserted.length > 0) {
|
|
1232
|
+
log(`Wrote ${scope.inserted.length} autoMode entr${scope.inserted.length === 1 ? "y" : "ies"} ("$defaults" kept):`);
|
|
1233
|
+
logList(log, scope.inserted);
|
|
1234
|
+
}
|
|
1235
|
+
if (scope.alreadyPresent.length > 0) {
|
|
1236
|
+
log(`Already present (${scope.alreadyPresent.length}):`);
|
|
1237
|
+
logList(log, scope.alreadyPresent);
|
|
1238
|
+
}
|
|
1239
|
+
if (!result.manifestRecorded) {
|
|
1240
|
+
errorLog(`warning: could not update ${BRIDGE_PERMISSIONS_MANIFEST_RELPATH}; doctor and removal may not see these entries.`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
else if (scope.status === "malformed" || scope.status === "io-error") {
|
|
1244
|
+
exitCode = 1;
|
|
1245
|
+
errorLog(`warning: ${scope.path} ${scope.status === "malformed" ? "is not valid JSON settings and was left unchanged" : "could not be written"}; ` +
|
|
1246
|
+
"these autoMode entries were NOT written:");
|
|
1247
|
+
logList(errorLog, scope.skipped);
|
|
1248
|
+
}
|
|
1249
|
+
return exitCode;
|
|
1250
|
+
}
|
|
1251
|
+
/** Split the consent text into its printed body and its final prompt line. */
|
|
1252
|
+
function splitConsentPrompt() {
|
|
1253
|
+
const at = USER_AUTO_MODE_CONSENT_PROMPT.lastIndexOf("\n");
|
|
1254
|
+
return {
|
|
1255
|
+
body: USER_AUTO_MODE_CONSENT_PROMPT.slice(0, at),
|
|
1256
|
+
prompt: USER_AUTO_MODE_CONSENT_PROMPT.slice(at + 1),
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Run the subcommand and return the process exit code. It never throws: an
|
|
1261
|
+
* unexpected failure is reported (without detail) on stderr and returned as `1`.
|
|
1262
|
+
*
|
|
1263
|
+
* - no flags, interactive: print the consent text, read one answer; only `y` or
|
|
1264
|
+
* `yes` writes. Blank, EOF, and a read failure write nothing and exit 0.
|
|
1265
|
+
* - no flags, no terminal: write nothing, print the entries it would add, exit 0.
|
|
1266
|
+
* - `--check --json`: read-only; one JSON object for the user scope, exit 0 even
|
|
1267
|
+
* when the scope is `malformed`.
|
|
1268
|
+
* - `--user-consented`: write without asking (reserved for `/drive-epic-run`).
|
|
1269
|
+
*
|
|
1270
|
+
* Only the user-settings step runs here; project rules and hooks are never touched.
|
|
1271
|
+
*/
|
|
1272
|
+
export async function runBridgePermissionsCli(argv, deps) {
|
|
1273
|
+
const parsed = parseBridgePermissionsArgv(argv);
|
|
1274
|
+
if (!parsed.ok) {
|
|
1275
|
+
deps.errorLog(parsed.error);
|
|
1276
|
+
deps.errorLog(getBridgePermissionsUsage());
|
|
1277
|
+
return 1;
|
|
1278
|
+
}
|
|
1279
|
+
try {
|
|
1280
|
+
if (parsed.mode === "help") {
|
|
1281
|
+
deps.log(getBridgePermissionsUsage());
|
|
1282
|
+
return 0;
|
|
1283
|
+
}
|
|
1284
|
+
if (parsed.mode === "check-json") {
|
|
1285
|
+
const inspection = await inspectBridgePermissions({ cwd: deps.cwd, homeDir: deps.homeDir, env: deps.env }, deps);
|
|
1286
|
+
const user = inspection.user;
|
|
1287
|
+
const payload = {
|
|
1288
|
+
state: user.state,
|
|
1289
|
+
path: user.path,
|
|
1290
|
+
};
|
|
1291
|
+
if ("missing" in user)
|
|
1292
|
+
payload.missing = user.missing;
|
|
1293
|
+
deps.log(JSON.stringify(payload));
|
|
1294
|
+
return 0;
|
|
1295
|
+
}
|
|
1296
|
+
if (parsed.mode === "user-consented") {
|
|
1297
|
+
const result = await applyUserAutoModeEntries({ cwd: deps.cwd, homeDir: deps.homeDir }, deps);
|
|
1298
|
+
return reportUserAutoModeWrite(result, deps.log, deps.errorLog);
|
|
1299
|
+
}
|
|
1300
|
+
// No flags.
|
|
1301
|
+
if (!deps.isTTY || !deps.promptLine) {
|
|
1302
|
+
const entries = buildUserAutoModeEntries();
|
|
1303
|
+
deps.log(`${userSettingsPath(deps.homeDir)} was not changed (no interactive terminal). It would add, to autoMode.allow and autoMode.environment:`);
|
|
1304
|
+
logList(deps.log, [...entries.allow, ...entries.environment]);
|
|
1305
|
+
deps.log("Run this command in a terminal to be asked, or let /drive-epic-run ask in your session.");
|
|
1306
|
+
return 0;
|
|
1307
|
+
}
|
|
1308
|
+
const { body, prompt } = splitConsentPrompt();
|
|
1309
|
+
deps.log(body);
|
|
1310
|
+
let answer = "";
|
|
1311
|
+
try {
|
|
1312
|
+
answer = String((await deps.promptLine(prompt)) ?? "").trim().toLowerCase();
|
|
1313
|
+
}
|
|
1314
|
+
catch {
|
|
1315
|
+
answer = "";
|
|
1316
|
+
}
|
|
1317
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1318
|
+
deps.log(`${userSettingsPath(deps.homeDir)} was not changed.`);
|
|
1319
|
+
return 0;
|
|
1320
|
+
}
|
|
1321
|
+
const result = await applyUserAutoModeEntries({ cwd: deps.cwd, homeDir: deps.homeDir }, deps);
|
|
1322
|
+
return reportUserAutoModeWrite(result, deps.log, deps.errorLog);
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
deps.errorLog("bridge-permissions: unexpected error; nothing further was changed.");
|
|
1326
|
+
return 1;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/** One-line prompt on stderr; EOF resolves as an empty answer so it can never hang. */
|
|
1330
|
+
function promptLineOnStderr(promptText) {
|
|
1331
|
+
return new Promise((resolve) => {
|
|
1332
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
1333
|
+
let answered = false;
|
|
1334
|
+
rl.on("close", () => {
|
|
1335
|
+
if (!answered)
|
|
1336
|
+
resolve("");
|
|
1337
|
+
});
|
|
1338
|
+
rl.question(promptText, (answer) => {
|
|
1339
|
+
answered = true;
|
|
1340
|
+
rl.close();
|
|
1341
|
+
resolve(answer);
|
|
1342
|
+
});
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
/** Production entry for the `bridge-permissions` subcommand: real filesystem, real home. */
|
|
1346
|
+
export async function runBridgePermissionsCliFromProcess(argv) {
|
|
1347
|
+
const isTTY = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
1348
|
+
return runBridgePermissionsCli(argv, {
|
|
1349
|
+
// The manifest belongs to the project in the current directory, so there is no
|
|
1350
|
+
// Git-root lookup: help says "run from the project root".
|
|
1351
|
+
cwd: process.cwd(),
|
|
1352
|
+
homeDir: os.homedir(),
|
|
1353
|
+
env: process.env,
|
|
1354
|
+
isTTY,
|
|
1355
|
+
promptLine: isTTY ? promptLineOnStderr : undefined,
|
|
1356
|
+
readFile: (filePath) => fs.readFile(filePath, "utf-8"),
|
|
1357
|
+
writeFile: (filePath, data) => fs.writeFile(filePath, data, { encoding: "utf-8" }),
|
|
1358
|
+
mkdir: (dirPath, options) => fs.mkdir(dirPath, options),
|
|
1359
|
+
log: (message) => console.log(message),
|
|
1360
|
+
errorLog: (message) => console.error(message),
|
|
1361
|
+
});
|
|
1362
|
+
}
|