diff --git a/bin/fm-azure-runner-routing.py b/bin/fm-azure-runner-routing.py index 10a7ca49526..fb9daf65d3a 100755 --- a/bin/fm-azure-runner-routing.py +++ b/bin/fm-azure-runner-routing.py @@ -245,7 +245,7 @@ def routing_lock(path): os.close(handle) -def operator_environment_values(expected_subscription): +def operator_environment_values(expected_subscription=None): """Return exact values from one validated read of the operator environment. The daemon step inherits only HOME and PATH. A routing selection therefore @@ -365,7 +365,10 @@ def operator_environment_values(expected_subscription): path, ", ".join(missing) ) ) - if values["FM_AZURE_SUBSCRIPTION_ID"] != expected_subscription: + if ( + expected_subscription is not None + and values["FM_AZURE_SUBSCRIPTION_ID"] != expected_subscription + ): refuse( "routing file subscription {} does not match the operator Azure environment subscription".format( expected_subscription diff --git a/bin/fm-crosscheck-slack-service.sh b/bin/fm-crosscheck-slack-service.sh index ce9ddaf6d44..602c112fffa 100755 --- a/bin/fm-crosscheck-slack-service.sh +++ b/bin/fm-crosscheck-slack-service.sh @@ -31,26 +31,42 @@ write_plist() { mkdir -p "$AGENT_DIR" "$LOG_DIR" temporary=$(mktemp "$AGENT_DIR/.$LABEL.XXXXXX") trap 'rm -f "$temporary"' EXIT - plutil -create xml1 "$temporary" - plutil -insert Label -string "$LABEL" "$temporary" - plutil -insert ProgramArguments -array "$temporary" - plutil -insert ProgramArguments.0 -string "$WRAPPER" "$temporary" - plutil -insert ProgramArguments.1 -string run "$temporary" - plutil -insert ProgramArguments.2 -string --config "$temporary" - plutil -insert ProgramArguments.3 -string "$CONFIG" "$temporary" - plutil -insert ProgramArguments.4 -string --keychain-only "$temporary" - plutil -insert EnvironmentVariables -dictionary "$temporary" - plutil -insert EnvironmentVariables.HOME -string "$HOME" "$temporary" - plutil -insert EnvironmentVariables.PATH -string "$PATH" "$temporary" - plutil -insert EnvironmentVariables.FM_CROSSCHECK_PYTHON -string "$interpreter" "$temporary" - plutil -insert EnvironmentVariables.FM_HOME -string "$FM_HOME" "$temporary" - plutil -insert EnvironmentVariables.FM_CROSSCHECK_SLACK_CONFIG \ - -string "$CONFIG" "$temporary" - plutil -insert RunAtLoad -bool true "$temporary" - plutil -insert KeepAlive -bool true "$temporary" - plutil -insert ThrottleInterval -integer 10 "$temporary" - plutil -insert StandardOutPath -string "$STDOUT_LOG" "$temporary" - plutil -insert StandardErrorPath -string "$STDERR_LOG" "$temporary" + "$interpreter" - "$temporary" "$LABEL" "$WRAPPER" "$CONFIG" "$HOME" \ + "$PATH" "$interpreter" "$FM_HOME" "$STDOUT_LOG" "$STDERR_LOG" <<'PY' +import plistlib +import sys + +( + destination, + label, + wrapper, + config, + home, + path, + interpreter, + fm_home, + stdout_log, + stderr_log, +) = sys.argv[1:] +document = { + "Label": label, + "ProgramArguments": [wrapper, "run", "--config", config, "--keychain-only"], + "EnvironmentVariables": { + "HOME": home, + "PATH": path, + "FM_CROSSCHECK_PYTHON": interpreter, + "FM_HOME": fm_home, + "FM_CROSSCHECK_SLACK_CONFIG": config, + }, + "RunAtLoad": True, + "KeepAlive": True, + "ThrottleInterval": 10, + "StandardOutPath": stdout_log, + "StandardErrorPath": stderr_log, +} +with open(destination, "wb") as handle: + plistlib.dump(document, handle, fmt=plistlib.FMT_XML, sort_keys=True) +PY chmod 600 "$temporary" validate_plist "$temporary" mv "$temporary" "$PLIST" diff --git a/bin/fm-crosscheck-slack.py b/bin/fm-crosscheck-slack.py index 3593d370817..c946ac42e2f 100755 --- a/bin/fm-crosscheck-slack.py +++ b/bin/fm-crosscheck-slack.py @@ -892,6 +892,41 @@ def crosscheck_core() -> Any: return module +@functools.lru_cache(maxsize=1) +def operator_azure_environment() -> dict[str, str]: + """Load the coordinator's proven fleet environment for launchd service use.""" + + module_path = BIN_DIR / "fm-azure-runner-routing.py" + spec = importlib.util.spec_from_file_location( + "fm_crosscheck_slack_azure_environment", module_path + ) + require( + spec is not None and spec.loader is not None, + "central Azure environment owner is unavailable", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + try: + values = module.operator_environment_values() + except SystemExit as exc: + raise SlackExposureError( + f"central Azure environment validation failed with exit {exc.code}" + ) from exc + require( + isinstance(values, dict) + and values + and all( + isinstance(name, str) + and name.startswith("FM_AZURE_") + and isinstance(value, str) + and value + for name, value in values.items() + ), + "central Azure environment owner returned malformed values", + ) + return dict(values) + + @functools.lru_cache(maxsize=128) def crosscheck_model_family(model: str) -> str: """Use the core gate's exact family classifier, never a Slack-local guess.""" @@ -2706,6 +2741,7 @@ def main() -> int: ) for name in (config.app_token_env, config.bot_token_env, config.github_token_env): os.environ.pop(name, None) + os.environ.update(operator_azure_environment()) if args.command == "attest-launch": path = Path(args.config) if args.config else default_config_path() config = load_config(path) @@ -2737,7 +2773,9 @@ def main() -> int: config = load_config(path) load_provenance_key(config.provenance_key_file) configured_credentials(config) - print("preflight: config, provenance key, and central credentials are ready") + print( + "preflight: config, provenance key, central credentials, and Azure fleet are ready" + ) return 0 if args.command == "selftest": path = Path(args.config) if args.config else default_config_path() diff --git a/docs/crosscheck-slack.md b/docs/crosscheck-slack.md index a018469d221..561aa263766 100644 --- a/docs/crosscheck-slack.md +++ b/docs/crosscheck-slack.md @@ -217,6 +217,8 @@ The launch agent is `~/Library/LaunchAgents/com.firstmate.crosscheck-slack.plist Logs are `$FM_HOME/logs/crosscheck-slack.log` and `$FM_HOME/logs/crosscheck-slack.error.log`. The launch agent contains no credential values. The launch agent persists the resolved absolute Python interpreter, executable PATH, HOME, and service configuration. +At preflight and every launch, the listener loads the existing owner-only `~/.fm-azure/fleet.env` through the Azure runner's bounded provenance-checked parser and passes only its allowlisted `FM_AZURE_*` values to Crosscheck children. +No Azure value is copied into the launch agent. Install and start execute selftest and credential preflight with exactly the emitted environment and require Keychain access, ignoring inherited token variables. Installation refuses before replacing an existing plist if validation fails. The listener also requires Keychain credentials on every launch, including launchd restarts. diff --git a/tests/fm-crosscheck-slack.test.sh b/tests/fm-crosscheck-slack.test.sh index 9fd44be0868..563783b87c2 100755 --- a/tests/fm-crosscheck-slack.test.sh +++ b/tests/fm-crosscheck-slack.test.sh @@ -1232,6 +1232,18 @@ fixture = root / "service-fixture" bin_dir = fixture / "bin" bin_dir.mkdir(parents=True) home.mkdir() +azure_dir = home / ".fm-azure" +azure_dir.mkdir() +fleet = azure_dir / "fleet.env" +fleet.write_text("""FM_AZURE_TENANT_ID=11111111-1111-1111-1111-111111111111 +FM_AZURE_SUBSCRIPTION_ID=22222222-2222-2222-2222-222222222222 +FM_AZURE_NAMING_PREFIX=fixture +FM_AZURE_STORAGE_NAME=fixturestorage +FM_AZURE_OWNER_TAG=fixture-owner +FM_AZURE_DEPLOYMENT_GENERATION=fixture-generation +FM_AZURE_BLOB_PE_NIC_RESOURCE_GUID=33333333-3333-3333-3333-333333333333 +""") +fleet.chmod(0o600) config_path = fixture / "config.json" config = json.loads(original_config.read_text()) config["keychain_services"] = dict(app_token="fixture-app", bot_token="fixture-bot", github_token="fixture-github") @@ -1301,6 +1313,13 @@ assert 'fixture-keychain-secret' not in plist.read_text() command = agent['ProgramArguments'] result = subprocess.run([command[0], 'preflight', *command[2:]], env=agent['EnvironmentVariables'], capture_output=True, text=True, timeout=30) assert result.returncode == 0, result.stdout + result.stderr +assert 'Azure fleet are ready' in result.stdout +fleet_off = fleet.with_suffix('.env.off') +fleet.rename(fleet_off) +result = invoke('start') +assert result.returncode != 0, 'start accepted a missing central Azure fleet environment' +assert not launch_log.exists(), 'missing Azure fleet validation mutated service state' +fleet_off.rename(fleet) result = invoke('start') assert result.returncode == 0, result.stdout + result.stderr assert 'bootstrap' in launch_log.read_text() diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 7f382def88d..fd4c7e9aaee 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -653,6 +653,8 @@ print(value["issuer"], value["task"]["task_id"], value["author"]["harness"], val assert_contains "$shape" "$leased_worktree" \ "Pi spawn launch provenance did not bind the leased task worktree" git -C "$leased_worktree" checkout -qb codex/crosscheck-provenance-fixture + git -C "$leased_worktree" config user.email fixture@example.com + git -C "$leased_worktree" config user.name Fixture printf 'authored after launch\n' > "$leased_worktree/crosscheck-provenance-fixture.txt" git -C "$leased_worktree" add crosscheck-provenance-fixture.txt git -C "$leased_worktree" commit -qm 'test: author after launch'