Skip to content

Commit 531d915

Browse files
committed
Harden installer verification and route traces/task ingestion via base tool
1 parent 9ca7301 commit 531d915

File tree

7 files changed

+188
-16
lines changed

7 files changed

+188
-16
lines changed

install.sh

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ set -eu
44
# Flow CLI installer
55
# Usage: curl -fsSL https://myflow.sh/install.sh | sh
66

7+
# Security posture:
8+
# - We require SHA-256 verification by default.
9+
# - Set FLOW_INSTALL_INSECURE=1 (or true/yes) to bypass verification.
10+
711
#region logging
812
if [ "${FLOW_DEBUG-}" = "true" ] || [ "${FLOW_DEBUG-}" = "1" ]; then
913
debug() { echo "$@" >&2; }
@@ -21,6 +25,13 @@ error() {
2125
echo "error: $@" >&2
2226
exit 1
2327
}
28+
29+
is_truthy() {
30+
case "${1:-}" in
31+
1|true|TRUE|yes|YES|y|Y) return 0 ;;
32+
*) return 1 ;;
33+
esac
34+
}
2435
#endregion
2536

2637
#region platform detection
@@ -94,6 +105,9 @@ validate_repo() {
94105

95106
validate_token() {
96107
token="$1"
108+
if [ -z "${token:-}" ]; then
109+
error "GitHub token is empty"
110+
fi
97111
case "$token" in
98112
*[!A-Za-z0-9._-]*)
99113
error "invalid GitHub token characters (refusing to use it)"
@@ -122,9 +136,9 @@ download_file() {
122136
if command -v curl >/dev/null 2>&1; then
123137
debug ">" curl -fsSL -o "$file" "$url"
124138
if [ "${FLOW_DEBUG-}" = "true" ] || [ "${FLOW_DEBUG-}" = "1" ]; then
125-
curl -fsSL -o "$file" "$url"
139+
curl -fsSL --proto '=https' --tlsv1.2 -o "$file" "$url"
126140
else
127-
curl -fsSL -o "$file" "$url" 2>/dev/null
141+
curl -fsSL --proto '=https' --tlsv1.2 -o "$file" "$url" 2>/dev/null
128142
fi
129143
elif command -v wget >/dev/null 2>&1; then
130144
debug ">" wget -qO "$file" "$url"
@@ -142,13 +156,13 @@ fetch_url() {
142156
token="${GITHUB_TOKEN:-${GH_TOKEN:-${FLOW_GITHUB_TOKEN:-}}}"
143157
if [ -n "${token:-}" ]; then
144158
validate_token "$token"
145-
curl -fsSL -H "Authorization: Bearer $token" "$url"
159+
curl -fsSL --proto '=https' --tlsv1.2 -H "Authorization: Bearer ${token}" "$url"
146160
else
147-
curl -fsSL "$url"
161+
curl -fsSL --proto '=https' --tlsv1.2 "$url"
148162
fi
149163
;;
150164
*)
151-
curl -fsSL "$url"
165+
curl -fsSL --proto '=https' --tlsv1.2 "$url"
152166
;;
153167
esac
154168
elif command -v wget >/dev/null 2>&1; then
@@ -238,6 +252,7 @@ install_flow() {
238252

239253
download_dir="$(mktemp -d)"
240254
tarball="$download_dir/flow.tar.gz"
255+
download_source="unknown"
241256

242257
asset_file="flow-${target}.tar.gz"
243258
legacy_os="$os"
@@ -255,12 +270,15 @@ install_flow() {
255270
info "flow: downloading..."
256271
if command -v curl >/dev/null 2>&1 && curl -fsSL -o "$tarball" "$cdn_url" 2>/dev/null; then
257272
debug "flow: downloaded from CDN"
273+
download_source="cdn"
258274
else
259275
debug "flow: trying GitHub..."
260276
if download_file "$github_url" "$tarball"; then
261277
asset_file="flow-${target}.tar.gz"
278+
download_source="github"
262279
elif download_file "$legacy_url" "$tarball"; then
263280
asset_file="$legacy_file"
281+
download_source="legacy"
264282
else
265283
error "download failed"
266284
fi
@@ -278,6 +296,17 @@ install_flow() {
278296
expected="$(get_checksum_for_file "$version" "$legacy_file" 2>/dev/null)" || true
279297
fi
280298
fi
299+
if [ -z "${expected:-}" ]; then
300+
if is_truthy "${FLOW_INSTALL_INSECURE-}"; then
301+
info "flow: warning: checksum not verified (FLOW_INSTALL_INSECURE=1)"
302+
elif [ "${download_source:-}" = "cdn" ]; then
303+
rm -rf "$download_dir" "$extract_dir" 2>/dev/null || true
304+
error "checksum verification failed for CDN download (checksums.txt missing or entry not found). Refusing to install.\nSet FLOW_INSTALL_INSECURE=1 to bypass (not recommended)."
305+
else
306+
info "flow: warning: checksum not verified (checksums.txt missing or entry not found; legacy release?)"
307+
expected=""
308+
fi
309+
fi
281310
if [ -n "${expected:-}" ]; then
282311
debug "flow: verifying checksum..."
283312
actual="$($shasum "$tarball" | awk '{print $1}')"
@@ -286,8 +315,12 @@ install_flow() {
286315
error "checksum mismatch"
287316
fi
288317
info "flow: checksum verified"
318+
fi
319+
else
320+
if is_truthy "${FLOW_INSTALL_INSECURE-}"; then
321+
info "flow: warning: sha256 tool not found, skipping checksum verification (FLOW_INSTALL_INSECURE=1)"
289322
else
290-
info "flow: warning: checksum not verified (checksums.txt missing or entry not found)"
323+
error "sha256 tool not found (need shasum or sha256sum). Refusing to install.\nSet FLOW_INSTALL_INSECURE=1 to bypass (not recommended)."
291324
fi
292325
fi
293326

readme.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ curl -fsSL https://raw.githubusercontent.com/nikivdev/flow/main/install.sh | sh
1414

1515
Then run `f --version` and `f doctor`.
1616

17+
The installer verifies SHA-256 checksums when available. If you are installing a legacy release
18+
that doesn't ship `checksums.txt`, it will warn and continue (GitHub download only). To bypass
19+
verification explicitly (not recommended), set `FLOW_INSTALL_INSECURE=1`.
20+
1721
## Upgrade
1822

1923
Upgrade to the latest release:
@@ -39,6 +43,9 @@ If you fork Flow (or publish releases under a different repo), set:
3943
- `FLOW_UPGRADE_REPO=owner/repo`
4044
- `FLOW_GITHUB_TOKEN` (or `GITHUB_TOKEN` / `GH_TOKEN`) to avoid GitHub API rate limits
4145

46+
If you are upgrading to a very old tag that doesn't ship `checksums.txt`, you can force bypassing
47+
checksum verification with `FLOW_UPGRADE_INSECURE=1` (not recommended).
48+
4249
## Supported Platforms
4350

4451
Release artifacts are built for:
@@ -63,6 +70,9 @@ f release signing store --p12 /path/to/cert.p12 --p12-password '...' --identity
6370
f release signing sync
6471
```
6572

73+
Note: Apple provides a `.cer` download, but CI signing needs a `.p12` that includes the private key.
74+
Export the "Developer ID Application: ..." identity as `.p12` from Keychain Access.
75+
6676
Notarization is optional for a CLI distributed via `curl | sh` (downloads via `curl`
6777
typically do not set the quarantine attribute), but can be added later if desired.
6878

src/base_tool.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use std::path::{Path, PathBuf};
2+
use std::process::{Command, Stdio};
3+
4+
use anyhow::{Context, Result};
5+
6+
pub fn resolve_bin() -> Option<PathBuf> {
7+
if let Ok(value) = std::env::var("FLOW_BASE_BIN") {
8+
let trimmed = value.trim();
9+
if !trimmed.is_empty() {
10+
return Some(PathBuf::from(trimmed));
11+
}
12+
}
13+
14+
// Prefer a more specific name, but fall back to the current base repo binary name.
15+
for name in ["base", "db"] {
16+
if let Ok(path) = which::which(name) {
17+
return Some(path);
18+
}
19+
}
20+
21+
None
22+
}
23+
24+
pub fn run_inherit_stdio(bin: &Path, args: &[String]) -> Result<()> {
25+
let status = Command::new(bin)
26+
.args(args)
27+
.stdin(Stdio::inherit())
28+
.stdout(Stdio::inherit())
29+
.stderr(Stdio::inherit())
30+
.status()
31+
.with_context(|| format!("failed to run {} {}", bin.display(), args.join(" ")))?;
32+
if !status.success() {
33+
anyhow::bail!("{} exited with {}", bin.display(), status);
34+
}
35+
Ok(())
36+
}
37+
38+
pub fn run_with_stdin(bin: &Path, args: &[String], stdin: &str) -> Result<()> {
39+
let mut child = Command::new(bin)
40+
.args(args)
41+
.stdin(Stdio::piped())
42+
.stdout(Stdio::null())
43+
.stderr(Stdio::inherit())
44+
.spawn()
45+
.with_context(|| format!("failed to spawn {} {}", bin.display(), args.join(" ")))?;
46+
47+
{
48+
use std::io::Write;
49+
let child_stdin = child.stdin.as_mut().context("failed to open stdin")?;
50+
child_stdin.write_all(stdin.as_bytes())?;
51+
}
52+
53+
let status = child.wait()?;
54+
if !status.success() {
55+
anyhow::bail!("{} exited with {}", bin.display(), status);
56+
}
57+
Ok(())
58+
}
59+

src/jazz_state_stub.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
use anyhow::Result;
22

33
use crate::history::InvocationRecord;
4+
use crate::base_tool;
45

5-
pub fn record_task_run(_record: &InvocationRecord) -> Result<()> {
6+
pub fn record_task_run(record: &InvocationRecord) -> Result<()> {
7+
// Best-effort: never fail the parent task run if base isn't installed or errors out.
8+
let Some(bin) = base_tool::resolve_bin() else {
9+
return Ok(());
10+
};
11+
12+
let Ok(payload) = serde_json::to_string(record) else {
13+
return Ok(());
14+
};
15+
16+
let args: Vec<String> = vec!["ingest".to_string(), "task-run".to_string()];
17+
let _ = base_tool::run_with_stdin(&bin, &args, &payload);
618
Ok(())
719
}
820

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod ai_server;
66
pub mod archive;
77
pub mod ask;
88
pub mod auth;
9+
pub mod base_tool;
910
pub mod changes;
1011
pub mod cli;
1112
pub mod code;

src/traces_stub.rs

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,48 @@
1-
use anyhow::Result;
1+
use anyhow::{Result, bail};
22

33
use crate::cli::{TraceSessionOpts, TraceSource, TracesOpts};
4+
use crate::base_tool;
45

5-
pub fn run(_opts: TracesOpts) -> Result<()> {
6-
println!("traces disabled (flow built without jazz2 support)");
7-
Ok(())
6+
pub fn run(opts: TracesOpts) -> Result<()> {
7+
let Some(bin) = base_tool::resolve_bin() else {
8+
bail!(
9+
"traces require the base tool (FLOW_BASE_BIN).\n\
10+
Install it, then retry.\n\
11+
(Expected `base` or `db` on PATH, or set FLOW_BASE_BIN=/path/to/base)"
12+
);
13+
};
14+
15+
let mut args: Vec<String> = vec!["trace".to_string(), "--limit".to_string(), opts.limit.to_string()];
16+
if opts.follow {
17+
args.push("--follow".to_string());
18+
}
19+
if let Some(project) = opts.project.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()) {
20+
args.push("--project".to_string());
21+
args.push(project.to_string());
22+
}
23+
args.push("--source".to_string());
24+
args.push(match opts.source {
25+
TraceSource::All => "all",
26+
TraceSource::Tasks => "tasks",
27+
TraceSource::Ai => "ai",
28+
}.to_string());
29+
30+
base_tool::run_inherit_stdio(&bin, &args)
831
}
932

1033
pub fn run_session(_opts: TraceSessionOpts) -> Result<()> {
11-
println!("traces disabled (flow built without jazz2 support)");
12-
Ok(())
34+
let Some(bin) = base_tool::resolve_bin() else {
35+
bail!(
36+
"trace session requires the base tool (FLOW_BASE_BIN).\n\
37+
Install it, then retry.\n\
38+
(Expected `base` or `db` on PATH, or set FLOW_BASE_BIN=/path/to/base)"
39+
);
40+
};
41+
42+
// Keep behavior compatible with Flow's old implementation: always show full session history.
43+
let mut args: Vec<String> = vec!["session".to_string()];
44+
args.push(_opts.path.display().to_string());
45+
base_tool::run_inherit_stdio(&bin, &args)
1346
}
1447

1548
pub fn trace_source_from_str(_value: &str) -> TraceSource {

src/upgrade.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ use crate::cli::UpgradeOpts;
2121

2222
const UPGRADE_CHECK_INTERVAL_HOURS: u64 = 24;
2323

24+
fn env_truthy(key: &str) -> bool {
25+
match env::var(key)
26+
.ok()
27+
.map(|v| v.trim().to_ascii_lowercase())
28+
.as_deref()
29+
{
30+
Some("1") | Some("true") | Some("yes") | Some("y") => true,
31+
_ => false,
32+
}
33+
}
34+
2435
fn upgrade_repo() -> Result<(String, String)> {
2536
if let Ok(value) = env::var("FLOW_UPGRADE_REPO") {
2637
if let Some((owner, repo)) = value.trim().split_once('/') {
@@ -602,11 +613,13 @@ pub fn run(opts: UpgradeOpts) -> Result<()> {
602613
let temp_tarball = env::temp_dir().join("flow_upgrade.tar.gz");
603614
download_with_progress(&client, &tarball_asset.browser_download_url, &temp_tarball)?;
604615

616+
let insecure = env_truthy("FLOW_UPGRADE_INSECURE");
605617
if let Some(asset) = checksums_asset {
606618
let temp_checksums = env::temp_dir().join("flow_upgrade_checksums.txt");
607619
download_with_progress(&client, &asset.browser_download_url, &temp_checksums)?;
608620
let checksums = fs::read_to_string(&temp_checksums)
609621
.context("failed to read downloaded checksums.txt")?;
622+
610623
if let Some(expected) = parse_sha256_from_checksums(&checksums, &tarball_asset.name) {
611624
let actual = sha256_file(&temp_tarball)?;
612625
if expected.to_lowercase() != actual.to_lowercase() {
@@ -618,15 +631,26 @@ pub fn run(opts: UpgradeOpts) -> Result<()> {
618631
);
619632
}
620633
println!("Checksum verified.");
634+
} else if insecure {
635+
eprintln!(
636+
"Warning: checksums.txt does not contain {}; skipping checksum verification (FLOW_UPGRADE_INSECURE=1).",
637+
tarball_asset.name
638+
);
621639
} else {
622-
println!(
623-
"Warning: checksums.txt does not contain {}; skipping checksum verification.",
640+
bail!(
641+
"checksums.txt does not contain {}. Refusing to install.\n\
642+
Set FLOW_UPGRADE_INSECURE=1 to bypass (not recommended).",
624643
tarball_asset.name
625644
);
626645
}
627646
let _ = fs::remove_file(&temp_checksums);
647+
} else if insecure {
648+
eprintln!(
649+
"Warning: checksums.txt not found in release assets; skipping checksum verification (FLOW_UPGRADE_INSECURE=1)."
650+
);
628651
} else {
629-
println!(
652+
// Back-compat for older releases (e.g. v0.1.0) that don't ship checksums.txt.
653+
eprintln!(
630654
"Warning: checksums.txt not found in release assets; skipping checksum verification."
631655
);
632656
}

0 commit comments

Comments
 (0)