Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ bridge-dev mode sudo
bridge-dev mode sudo --off
```

For a **single local shell helper** with explicit verification and recovery
instructions, use [scripts/bridge-dev-recover.sh](scripts/bridge-dev-recover.sh):
`sh bridge-dev-recover.sh check`, `on 60`, or `off`.
It does not deploy code or replace the independent operator.

Full System displays `Enable Full System (sudo)? [Y/n]`. Press Enter or `y` to approve, or `n` to decline. Use `--duration 60` for an optional timed session. **The portable installer does not install the privileged host controller or automatically activate sudo.** [Linux controller setup](docs/HOST_CONTROLLER.md).

## Platforms
Expand Down
35 changes: 35 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,41 @@ break native gateway tools. After disable/remove, stale manifests cannot authori
calls. Clients that cache tools may require reconnecting or a new chat even after
tool-list notifications were sent.

## Full System (sudo) CLI and verification

One local-shell helper is available at
[`scripts/bridge-dev-recover.sh`](../scripts/bridge-dev-recover.sh).
Download/review it and run `sh bridge-dev-recover.sh check` to inspect the
managed launcher, or `sh bridge-dev-recover.sh on 60` in an independent
terminal to request an explicitly confirmed temporary lease.
`sh bridge-dev-recover.sh off` revokes the lease. The script uses the
managed `/usr/local/bin/bridge-dev`, never whichever unrelated executable
appears first on PATH. It verifies the operator's local signed lease and
attempts to revert to Read & Write when an activation fails verification.
Remote MCP acceptance still requires `agent_status` in ChatGPT. It never
installs, upgrades, restarts or deploys the service; production releases
use the separately reviewed watchdog/rollback controller.

On managed Linux, `bridge-dev mode sudo` requests independent operator
confirmation. Prefer `bridge-dev mode sudo --duration 60` to a permanent
lease; `bridge-dev mode sudo --off` deactivates it. The portable installer
does not provision the privileged controller.

If `bridge-dev mode sudo` prints the outdated
`strict | standard | yolo` usage, inspect the executable selected by
`type -a bridge-dev` (fish) or `command -v bridge-dev` (POSIX shells).
The managed launcher is `/usr/local/bin/bridge-dev`, which forwards to
`/opt/bridge-dev/dist/cli.js`. An older user-local executable can shadow
it. Inspect before changing anything; **do not** overwrite the managed
gateway with the portable installer.

An operator-signed lease and verified controller activation are not proof
that the running gateway accepted Full System. Check `bridge-dev mode
status` in the independent terminal and the actual MCP `agent_status`.
Require `full_machine_access: true` before relying on host-root access.
A mismatch is a failed acceptance, not a reason to bypass policy or issue
another permanent lease.

## Policy and cleanup

`POLICYGATE_CONTEXT_REQUIRED` means the session needs `system_context` before a
Expand Down
119 changes: 119 additions & 0 deletions scripts/bridge-dev-recover.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/bin/sh
# BridgeDev managed Linux operator recovery. No install, no service restart.
# Run locally in your own terminal; never through MCP or with sudo sh.
set -eu

CLI=/usr/local/bin/bridge-dev
OPERATOR=/opt/bridge-dev/scripts/policygate-operator.mjs

say() { printf '[BridgeDev] %s\n' "$*"; }
die() { say "ERROR: $*" >&2; exit 1; }
usage() {
cat <<'USAGE'
Usage: sh bridge-dev-recover.sh [check | on [minutes] | off]
check Read-only status (default); never prompts for sudo
on 60 Request a time-limited Full System lease (1-120 min)
off Revoke Full System and return to Read & Write
This file does not install, update, restart, or deploy BridgeDev.
Managed service releases use their separate, watchdog-backed deploy controller.
USAGE
}
need_host() {
[ "$(uname -s)" = Linux ] || die "Managed mode is Linux-only"
[ -x "$CLI" ] || die "Managed CLI missing at $CLI"
[ -f "$OPERATOR" ] || die "Managed operator script missing at $OPERATOR"
[ -x /usr/bin/node ] || die "Node missing at /usr/bin/node"
command -v sudo >/dev/null 2>&1 || die "sudo is unavailable"
}
operator_status() {
sudo /usr/bin/node "$OPERATOR" status
}
active_lease() {
case "$1" in
*"Current Mode : FULL SYSTEM / sudo ("*) return 0 ;;
*) return 1 ;;
esac
}
check() {
say "Selected managed CLI: $CLI"
selected=$(command -v bridge-dev 2>/dev/null || :)
if [ -n "$selected" ] && [ "$selected" != "$CLI" ]; then
say "Your PATH selects $selected instead; using the managed CLI explicitly."
fi
"$CLI" mode status || :
say "Local CLI state alone does not prove gateway root authorization."
say "Verify MCP agent_status returns full_machine_access=true before relying on sudo."
say "Read-only check complete. No mode or service changes."
}
rollback() {
say "Activation was not verified; attempting operator-controlled rollback to Read & Write."
if "$CLI" mode sudo --off; then
if result=$(operator_status); then
printf '%s\n' "$result"
if ! active_lease "$result"; then
say "Rollback verified by independent operator status."
return 0
fi
fi
fi
say "ROLLBACK UNVERIFIED. Inspect the operator/controller and existing jobs; do not retry activation."
return 1
}
activate() {
duration=${1:-60}
case "$duration" in
''|*[!0-9]*) die "Duration must be an integer from 1 to 120" ;;
esac
[ "$duration" -ge 1 ] && [ "$duration" -le 120 ] || die "Duration must be 1-120 minutes"
[ -t 0 ] && [ -t 1 ] || die "Activation requires an independent interactive local terminal"
say "Checking independent operator state first; no existing lease will be overwritten."
before=$(operator_status) || die "Cannot verify operator status; no activation attempted"
printf '%s\n' "$before"
if active_lease "$before"; then
die "An existing Full System lease is active. Check/revoke it first; no new lease issued."
fi
say "Requesting Full System for $duration minute(s). The operator will ask you to confirm."
# Old CLI builds can return nonzero when root-owned lease metadata is not
# readable by the ordinary user. Do not trust that exit code alone.
cli_code=0
"$CLI" mode sudo --duration "$duration" || cli_code=$?
if ! after=$(operator_status); then
rollback || :
die "Operator status unavailable after request; lease cannot be considered verified"
fi
printf '%s\n' "$after"
if ! active_lease "$after"; then
# If user declined, no new lease exists; still leave the base mode as-is.
if [ "$cli_code" -eq 0 ]; then
say "No Full System lease was issued (possibly declined)."
exit 1
fi
rollback || :
die "Activation failed and rollback was attempted"
fi
if [ "$cli_code" -ne 0 ]; then
say "CLI returned $cli_code, but the independent operator reports a signed active lease."
say "This may be a stale/unreadable CLI status, not proof of gateway acceptance."
fi
say "Operator lease VERIFIED. Remote gateway acceptance is still UNVERIFIED."
say "Check MCP agent_status for full_machine_access=true. If absent, run: sh bridge-dev-recover.sh off"
}
deactivate() {
[ -t 0 ] && [ -t 1 ] || die "Deactivation requires an independent local terminal"
say "Revoking Full System using the managed operator."
"$CLI" mode sudo --off || die "Revoke command failed; inspect operator status and existing jobs"
after=$(operator_status) || die "Revoke requested, but independent status is unavailable"
printf '%s\n' "$after"
if active_lease "$after"; then
die "Full System still active; do not assume revocation"
fi
say "Full System inactive according to the operator. Confirm MCP agent_status reports full_machine_access=false."
}

case "${1:-check}" in
--help|-h|help) usage ;;
check) [ "$#" -eq 1 ] || [ "$#" -eq 0 ] || die "Unexpected arguments"; need_host; check ;;
on) [ "$#" -le 2 ] || die "Unexpected arguments"; need_host; activate "${2:-60}" ;;
off) [ "$#" -eq 1 ] || die "Unexpected arguments"; need_host; deactivate ;;
*) usage; die "Unknown action: $1" ;;
esac
22 changes: 17 additions & 5 deletions scripts/policygate-operator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,26 @@ if (action === "yolo") {
const temporary=yoloLeasePath+"."+randomUUID()+".tmp";
fs.writeFileSync(temporary, JSON.stringify(leaseData, null, 2) + "\n", { mode: 0o644,flag:"wx" });
fs.renameSync(temporary,yoloLeasePath);
if(process.env.POLICYGATE_TEST_OPERATOR!=="1"&&fs.existsSync("/usr/local/bin/bridge-dev-controller")){
const activated=spawnSync("/usr/local/bin/bridge-dev-controller",["activate"],{stdio:"inherit",env:{PATH:"/usr/bin:/bin"},timeout:30000});
const controllerPresent=process.env.POLICYGATE_TEST_OPERATOR!=="1"&&fs.existsSync("/usr/local/bin/bridge-dev-controller");
if(controllerPresent){
const helper="/usr/local/bin/bridge-dev-controller";
const activated=spawnSync(helper,["activate"],{stdio:"inherit",env:{PATH:"/usr/bin:/bin"},timeout:30000});
if(activated.status!==0){fs.unlinkSync(yoloLeasePath);throw new Error("Host controller activation failed; YOLO lease removed");}
// Exit zero alone is insufficient: the protected activation record must
// match this signed host-scoped lease before we report local success.
if(!activationFile||!verifyLeaseActivation(activationFile,leaseData,pubFile)){
const revoked=spawnSync(helper,["mode","standard"],{stdio:"inherit",env:{PATH:"/usr/bin:/bin"},timeout:30000});
if(fs.existsSync(yoloLeasePath))fs.unlinkSync(yoloLeasePath);
throw new Error("HOST_CONTROLLER_ACTIVATION_UNVERIFIED: local root lease revoked; "+
(revoked.status===0?"controller restored to STANDARD.":"controller rollback unverified; inspect operator status and jobs."));
}
}

console.log(permanent?"Full System ENABLED until manually disabled.":"Full System ENABLED for "+durationMin+" minutes.");
if(!permanent)console.log(`Active until: ${new Date(expires_at).toISOString()}`);
console.log("To deactivate immediately, run: policygate-operator.mjs yolo --off");
console.log(permanent?"Full System lease issued until manually disabled.":"Full System lease issued for "+durationMin+" minutes.");
if(!permanent)console.log(`Lease expires: ${new Date(expires_at).toISOString()}`);
console.log(controllerPresent?"Host-controller activation record VERIFIED.":"Isolated test operator: no host controller activation.");
console.log("Running MCP gateway acknowledgment is UNVERIFIED; check bridge-dev mode status and MCP agent_status.");
console.log("To deactivate immediately, run: bridge-dev mode sudo --off");
process.exit(0);
}

Expand Down
16 changes: 16 additions & 0 deletions scripts/test-bridge-dev-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ try {
const invalid=run(["unknown-command","--json"]);assert.equal(invalid.status,1);assert.equal(JSON.parse(invalid.stderr).code,"BRIDGEDEV_ERROR");
const help=run(["help"]);assert.equal(help.status,0,help.stderr);
assert.match(help.stdout,/mode read-write/);assert.match(help.stdout,/mode sudo --duration 60/);
const recovery=fs.readFileSync("scripts/bridge-dev-recover.sh","utf8");
assert.match(recovery,/CLI=\/usr\/local\/bin\/bridge-dev/);
assert.match(recovery,/active_lease\(\)/);
assert.match(recovery,/rollback\(\)/);
assert.match(recovery,/full_machine_access=true/);
assert.doesNotMatch(recovery,/sudo\s+(?:rm|chown|chmod|systemctl)|\bexec\s+sudo\b/);
if(process.platform!=="win32"){
const syntax=spawnSync("sh",["-n","scripts/bridge-dev-recover.sh"],{encoding:"utf8"});
assert.equal(syntax.status,0,syntax.stderr);
const helpScript=spawnSync("sh",["scripts/bridge-dev-recover.sh","--help"],{encoding:"utf8"});
assert.equal(helpScript.status,0,helpScript.stderr);
assert.match(helpScript.stdout,/check.*on.*off/);
const deniedScript=spawnSync("sh",["scripts/bridge-dev-recover.sh","on","121"],{encoding:"utf8"});
assert.notEqual(deniedScript.status,0);
// No actual activation is possible from the test environment.
}
const denied=run(["mode","sudo"]);assert.notEqual(denied.status,0);
assert.match(denied.stderr,/portable mode cannot elevate/);
const access=run(["access"]);
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-host-controller.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ try{
const operatorEnv={...process.env,POLICYGATE_TEST_OPERATOR:'1',POLICYGATE_OPERATOR_PRIVATE_KEY:privateKey,POLICYGATE_APPROVAL_PUBLIC_KEY:config.public_key,POLICYGATE_YOLO_LEASE:config.lease,POLICYGATE_MODE_FILE:config.mode_file};
for(const args of [['yolo','--duration','0'],['yolo','--duration','121'],['yolo','--duration','1junk'],['yolo','--duration'],['yolo','--durtion','10'],['yolo','--unknown'],['yolo','--until-off','--duration','5']])assert.notEqual(spawnSync(process.execPath,['scripts/policygate-operator.mjs',...args],{env:operatorEnv,encoding:'utf8'}).status,0);
const activated=spawnSync(process.execPath,['scripts/policygate-operator.mjs','mode','yolo','--duration','1'],{env:operatorEnv,encoding:'utf8'});assert.equal(activated.status,0,activated.stderr);
assert.match(activated.stdout,/Running MCP gateway acknowledgment is UNVERIFIED/);
assert.doesNotMatch(activated.stdout,/Full System ENABLED/);
const issued=JSON.parse(await fs.readFile(config.lease,'utf8'));assert.ok(issued.expires_at>Date.now()&&issued.expires_at<=Date.now()+60_000);
const revoked=spawnSync(process.execPath,['scripts/policygate-operator.mjs','yolo','--off'],{env:operatorEnv,encoding:'utf8'});assert.equal(revoked.status,0,revoked.stderr);
const foreverCommand=spawnSync(process.execPath,['scripts/policygate-operator.mjs','yolo'],{env:operatorEnv,encoding:'utf8'});
Expand Down
36 changes: 29 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,35 +220,40 @@ function cmdAccess(): void {
console.log("Keep these credentials private. For ChatGPT Web, configure a separate HTTPS MCP route.");
}

function runOperator(args: string[]): void {
function runOperator(args: string[]): boolean {
if (process.platform !== "linux") {
console.error("Signed operator mode control requires the hardened Linux installation.");
process.exitCode = 1;
return;
return false;
}
const script = path.join(getActivePath("opt"), "scripts", "policygate-operator.mjs");
if (!fs.existsSync(script)) {
console.error("Operator script not installed: " + script);
process.exitCode = 1;
return;
return false;
}
// The operator script independently requires real root UID and an interactive terminal.
const run = spawnSync("sudo", ["--", process.execPath, script, ...args], {stdio:"inherit"});
if (run.error || run.status !== 0) {
console.error("Operator authorization failed. Use an independent local root terminal.");
process.exitCode = run.status || 1;
return false;
}
return true;
}

async function cmdMode(args: string[]) {
const target = args[0]?.toLowerCase();
if (!target || target === "status") {
const reading = getActiveMode();
const label = reading.mode === "YOLO" ? "Full System (sudo)" :
reading.mode === "STANDARD" ? "Read & Write" : "Unavailable (internal safety lock)";
reading.mode === "STANDARD" ? "Read & Write" :
reading.mode === "STRICT" ? "Unavailable (internal safety lock)" : "Operator-only / unverified";
console.log("Mode: " + label + " (" + reading.detail + ")");
if (reading.mode === "STRICT" || reading.mode === "UNKNOWN")
if (reading.mode === "STRICT")
console.log("Protected state prevents Read & Write. Use 'bridge-dev mode read-write' from your own terminal.");
if (reading.mode === "UNKNOWN")
console.log("Mode state is not readable here. Verify with the independent operator and MCP agent_status.");
return;
}
if (["read-write","readwrite","rw","standard"].includes(target)) {
Expand All @@ -261,8 +266,25 @@ async function cmdMode(args: string[]) {
console.error("Full System (sudo) needs the optional Linux host controller; portable mode cannot elevate.");
process.exitCode=1;return;
}
if(args.includes("--off")) runOperator(["mode","standard"]);
else runOperator(["yolo", ...args.slice(1)]);
if(args.includes("--off")) {
runOperator(["mode","standard"]);
return;
}
if (!runOperator(["yolo", ...args.slice(1)])) return;
// An issued operator lease is not evidence that the local controller
// state actually authorizes Full System.
const mode = getActiveMode();
if (mode.mode === "STRICT" || mode.mode === "STANDARD") {
console.error("Full System not confirmed: protected state reports " + mode.mode);
console.error("Inspect the independent operator and MCP agent_status; do not assume remote root access.");
process.exitCode = 1;
} else if (mode.mode === "UNKNOWN") {
// Normal user accounts may not read root-owned lease files. The root
// operator verified its own activation record, but only the running
// gateway's agent_status can confirm remote host-root authorization.
console.log("Local lease state is operator-only (" + mode.detail + ").");
console.log("Check MCP agent_status for full_machine_access=true before relying on Full System.");
}
return;
}
console.error("Usage: bridge-dev mode [read-write | sudo [--duration <1-120>] | sudo --off]");
Expand Down
Loading