@liustack/modlens 3.1.0 → 3.1.1

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/docs/security.md CHANGED
@@ -20,7 +20,9 @@ ModLens invokes `agy` with `--dangerously-skip-permissions` because prompt mode
20
20
 
21
21
  The `claude-cli` provider runs with `--allowedTools Read` only, so it can read local files and nothing else.
22
22
 
23
- Both subprocess providers also run in a throwaway directory containing only the one image, created fresh per call and removed afterward. Text inside an image is untrusted, so an injection could otherwise steer a broadly-permissioned agent into reading files that sit next to the original. A directory of one removes that reach. Passing `--workdir` opts out and runs where you point it.
23
+ Both subprocess providers also run in a throwaway directory created fresh per call and removed afterward. For a local image it holds a private copy of that one image and nothing else, and it is a real copy, never a hardlink, so a provider writing to its temp path cannot touch the original. For a remote image the directory is empty and the agent downloads into it. Without this, text inside an image could steer a broadly-permissioned agent into reading files next to the original, or whatever project the caller happened to be in. Passing `--workdir` opts out and runs where you point it.
24
+
25
+ This is exposure reduction, not an OS sandbox: the agent can still read absolute paths, reach the network, and spawn processes. Treat it as a narrower default, not a security boundary. For images you do not trust, prefer an inline API provider (`-p gemini-api`), which hands the bytes to an HTTP endpoint and runs no local agent.
24
26
 
25
27
  ## Image content is untrusted input
26
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,8 @@
54
54
  "node": ">=22.13"
55
55
  },
56
56
  "dependencies": {
57
- "commander": "^13.1.0"
57
+ "commander": "^13.1.0",
58
+ "undici": "^8.10.0"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@biomejs/biome": "^2.5.7",
@@ -33,11 +33,11 @@ The launcher finds a working way to run modlens and forwards your arguments to i
33
33
 
34
34
  ### If you cannot run the launcher script
35
35
 
36
- Some harnesses forbid running scripts. Reason through the same order by hand and run the first line that works (the pinned version is 3.0.0):
36
+ Some harnesses forbid running scripts. Reason through the same order by hand and run the first line that works (the pinned version is 3.1.1):
37
37
 
38
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.0.0: `modlens <args>`.
39
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.0.0 modlens <args>`.
40
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.0.0 <args>`.
38
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.1.1: `modlens <args>`.
39
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.1.1 modlens <args>`.
40
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.1.1 <args>`.
41
41
  4. Otherwise none of these runtimes is here. Tell the user no JavaScript runtime was found and that installing Node 22.13+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
42
42
 
43
43
  `references/runtime.md` documents the version pin, the compatibility rule, and the diagnostic fields.
@@ -84,9 +84,10 @@ Harnesses rarely hand you a clean path. First identify which harness you are in,
84
84
 
85
85
  **Claude Code, Pi, or OpenCode** (no path tag anywhere; the image reads as `[Unsupported Image]`, a bare `[Image #1]`, or an attachment you simply cannot see):
86
86
 
87
- - None of these harnesses writes pasted images to a regular temp file, but all of them persist user messages locally before any gateway strips them: Claude Code and Pi in session JSONL files (`~/.claude/projects/`, `~/.pi/agent/sessions/`), OpenCode in a SQLite database (`~/.local/share/opencode/opencode.db`, read via node:sqlite, needs Node 22.5+). Run `modlens recover-paste` from the project directory the conversation is happening in (add `--count <n>` for several images). It detects which harness it is running inside (process ancestry, then env fingerprints) and reads ONLY that harness's storage, so another tool's old sessions cannot leak in. In Claude Code it also targets your exact session automatically via the injected CLAUDE_CODE_SESSION_ID; `--session <id>` (e.g. from the ${CLAUDE_SESSION_ID} substitution) is only needed to override.
87
+ - None of these harnesses writes pasted images to a regular temp file, but all of them persist user messages locally before any gateway strips them: Claude Code and Pi in session JSONL files (`~/.claude/projects/`, `~/.pi/agent/sessions/`), OpenCode in a SQLite database (`~/.local/share/opencode/opencode.db`, read via node:sqlite, needs Node 22.5+; Bun cannot load node:sqlite, so if the launcher resolved to bunx, OpenCode recovery needs a real Node install). Run `modlens recover-paste` from the project directory the conversation is happening in (add `--count <n>` for several images). It detects which harness it is running inside (process ancestry, then env fingerprints) and reads ONLY that harness's storage, so another tool's old sessions cannot leak in. In Claude Code it also targets your exact session automatically via the injected CLAUDE_CODE_SESSION_ID; `--session <id>` (e.g. from the ${CLAUDE_SESSION_ID} substitution) is only needed to override.
88
88
  - The output is JSON with real file paths, ordered oldest to newest, so the LAST path is the user's most recent paste. Analyze that one first. Entries carry `filename` (the original attachment name) when the harness stored one; if the user's message or an error mentions a filename, match on it.
89
89
  - Run every command yourself: `recover-paste`, then `modlens -i <path>` on the recovered file, then answer from the JSON. Never ask the user to run modlens or to relay paths.
90
+ - When the analysis is done, delete the recovered files: they are private copies of the user's pasted images sitting in the temp dir, and nothing cleans them up until the OS does. Remove the recovery output directory (each entry's `path` sits inside it), unless the user asked to keep the files.
90
91
  - The output's `detected` field names the harness scope that was applied. If it is absent, detection failed and every store was scanned by newest-image timestamp: before describing anything, check that `harness` and `filename` match what you expect, force the scope with `--harness <claude-code|pi|opencode>` if they do not, and when in doubt ask the user for the file instead of describing the wrong image.
91
92
  - Recovery is scoped to this project: the harness's own record of its working directory is checked, not just the directory name, so images from a neighbouring project are never handed over. Recovered files are private to the user (0600).
92
93
  - If recovery fails (session storage is each harness's internals and may change), ask the user to drag the image file into the terminal or type its path.
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.1.0
11
+ - Pinned CLI version: 3.1.1
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -22,7 +22,7 @@ launcher/reference copies ever drift from `package.json`.
22
22
  Each call resolves a way to run the CLI, in this order:
23
23
 
24
24
  1. **A compatible `modlens` already on `PATH`** — run it directly, by name.
25
- 2. **`npx` present** — `npx --yes --package @liustack/modlens@<pinned> modlens <args>`.
25
+ 2. **`npx` present, and `node` meets the CLI's 22.13 floor** — `npx --yes --package @liustack/modlens@<pinned> modlens <args>`. An npx sitting on an older node is skipped: it would select a path known to fail at run time.
26
26
  3. **`bunx` present** — `bunx --bun @liustack/modlens@<pinned> <args>`.
27
27
  4. **A native artifact** — reserved for phase B. None is published yet, so this
28
28
  branch reports `nativeArtifact.available: false` and moves on.
@@ -43,7 +43,9 @@ instead.
43
43
 
44
44
  ## Cache and permissions (phase B, not active yet)
45
45
 
46
- Phase A never downloads anything. When native artifacts land in phase B, the
46
+ Phase A ships no native artifact. The `npx` and `bunx` paths fetch the pinned
47
+ npm package on first use and cache it (that is how those runners work); nothing
48
+ else is ever downloaded. When native artifacts land in phase B, the
47
49
  launchers will cache them per user, keyed by version, and launch them by
48
50
  absolute path:
49
51
 
@@ -67,7 +69,9 @@ would have set.
67
69
  `arm64` / `x64`).
68
70
  - `checked.pathCli` — `{ present, path, version, compatible }` for a `modlens`
69
71
  on `PATH`, with `compatible` applying the rule above.
70
- - `checked.npx`, `checked.bunx` — `{ present, path }` visibility of each runner.
72
+ - `checked.npx` — `{ present, path, nodeMeetsFloor }`; `nodeMeetsFloor` is whether
73
+ the local node satisfies the CLI's 22.13 floor, required for the npx path.
74
+ - `checked.bunx` — `{ present, path }`.
71
75
  - `checked.node` — `{ present, version }`.
72
76
  - `nativeArtifact` — `{ available, note }`; `available` is `false` in phase A.
73
77
  - `selected` — the resolved path: `path`, `npx`, `bunx`, or `none`.
@@ -76,9 +80,15 @@ would have set.
76
80
  - `cliDoctor` — when a CLI is resolvable, the CLI's own `doctor --json` report
77
81
  (provider, config, and harness diagnosis) is nested here; `null` otherwise.
78
82
 
79
- `doctor` is offline and spends no quota: it inspects the local environment and,
80
- when it can, chains the CLI's own offline `doctor`. It makes no network request
81
- of its own.
83
+ `doctor` spends no quota. The launcher's own diagnosis is offline: it inspects
84
+ the local environment and makes no network request of its own. Chaining the
85
+ CLI's `doctor` through the npx or bunx path can download the pinned package the
86
+ first time (that is how those runners work); after that it is served from the
87
+ local cache.
88
+
89
+ One capability note for the bunx path: Bun cannot load `node:sqlite`, which
90
+ OpenCode paste recovery needs, so on a machine where the launcher resolved to
91
+ bunx, `recover-paste` for OpenCode requires installing Node 22.13+ instead.
82
92
 
83
93
  ## Delivery form: local CLI, long term
84
94
 
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
24
24
  # package.json version, and the release script rewrites it on every bump.
25
25
  $Package = '@liustack/modlens'
26
26
  $Bin = 'modlens'
27
- $Pinned = '3.1.0'
27
+ $Pinned = '3.1.1'
28
28
  # -------------------------------------------------------------------------------
29
29
 
30
30
  $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
@@ -41,6 +41,7 @@ $script:BunxPresent = $false
41
41
  $script:BunxPath = $null
42
42
  $script:NodePresent = $false
43
43
  $script:NodeVer = $null
44
+ $script:NodeFloorOk = $false
44
45
  $script:Selected = 'none'
45
46
 
46
47
  # First "X.Y.Z" token printed by `$Bin --version`.
@@ -68,6 +69,24 @@ function Test-Compatible {
68
69
  return ($fPat -ge $pPat)
69
70
  }
70
71
 
72
+ # The npx path runs the CLI on this machine's node, so npx is only usable when
73
+ # node itself meets the CLI's floor. An old node with a working npx used to be
74
+ # selected anyway, a path known to fail at run time.
75
+ $NodeFloor = '22.13.0'
76
+ function Test-NodeMeetsFloor {
77
+ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false }
78
+ try { $nv = ((& node --version 2>$null) -replace '^v', '') } catch { return $false }
79
+ if (-not $nv) { return $false }
80
+ $n = $nv -split '\.'
81
+ $f = $NodeFloor -split '\.'
82
+ if ($n.Count -lt 2) { return $false }
83
+ $nMaj = [int]$n[0]; $nMin = [int]$n[1]
84
+ $fMaj = [int]$f[0]; $fMin = [int]$f[1]
85
+ if ($nMaj -gt $fMaj) { return $true }
86
+ if ($nMaj -lt $fMaj) { return $false }
87
+ return ($nMin -ge $fMin)
88
+ }
89
+
71
90
  # Return exactly one word: the chosen launch path.
72
91
  function Resolve-LaunchKind {
73
92
  $cli = Get-Command $Bin -ErrorAction SilentlyContinue
@@ -75,7 +94,7 @@ function Resolve-LaunchKind {
75
94
  $v = Get-CliVersion
76
95
  if ($v -and (Test-Compatible $v)) { return 'path' }
77
96
  }
78
- if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
97
+ if ((Get-Command npx -ErrorAction SilentlyContinue) -and (Test-NodeMeetsFloor)) { return 'npx' }
79
98
  if (Get-Command bunx -ErrorAction SilentlyContinue) { return 'bunx' }
80
99
  # Phase B goes here: check a versioned user cache, then download and verify a
81
100
  # native artifact into it. Any such download must use curl.exe (written in
@@ -127,6 +146,7 @@ function Collect {
127
146
  if ($node) {
128
147
  $script:NodePresent = $true
129
148
  try { $script:NodeVer = ((& node --version 2>$null) -replace '^v', '') } catch { $script:NodeVer = $null }
149
+ $script:NodeFloorOk = Test-NodeMeetsFloor
130
150
  }
131
151
 
132
152
  $script:Selected = Resolve-LaunchKind
@@ -138,15 +158,19 @@ function Build-DiagnosisJson {
138
158
  param($Chained)
139
159
  $checked = [ordered]@{
140
160
  pathCli = [ordered]@{ present = $script:CliPresent; path = $script:CliPath; version = $script:CliVer; compatible = $script:CliCompat }
141
- npx = [ordered]@{ present = $script:NpxPresent; path = $script:NpxPath }
161
+ npx = [ordered]@{ present = $script:NpxPresent; path = $script:NpxPath; nodeMeetsFloor = $script:NodeFloorOk }
142
162
  bunx = [ordered]@{ present = $script:BunxPresent; path = $script:BunxPath }
143
163
  node = [ordered]@{ present = $script:NodePresent; version = $script:NodeVer }
144
164
  }
145
165
  $steps = @()
146
166
  if ($script:Selected -eq 'none') {
147
167
  $major = $Pinned.Split('.')[0]
168
+ $first = "Install Node 22.13+ from https://nodejs.org so npx can run $Package@$Pinned, then re-run this launcher."
169
+ if ($script:NpxPresent -and (-not $script:NodeFloorOk)) {
170
+ $first = "npx is present but node $(if ($script:NodeVer) { $script:NodeVer } else { 'missing' }) is below the $NodeFloor floor this CLI needs. Upgrade Node at https://nodejs.org, then re-run this launcher."
171
+ }
148
172
  $steps = @(
149
- "Install Node 22.13+ from https://nodejs.org so npx can run $Package@$Pinned, then re-run this launcher.",
173
+ $first,
150
174
  "No JavaScript runtime? Install Bun from https://bun.sh to use bunx, or put a compatible $Bin (major $major, at or above $Pinned) on PATH."
151
175
  )
152
176
  }
@@ -178,7 +202,12 @@ function Write-DiagnosisText {
178
202
  else {
179
203
  Write-Output (" {0} on PATH: no" -f $Bin)
180
204
  }
181
- Write-Output (" npx: {0}" -f $(if ($script:NpxPresent) { $script:NpxPath } else { 'no' }))
205
+ $npxDesc = 'no'
206
+ if ($script:NpxPresent) {
207
+ if ($script:NodeFloorOk) { $npxDesc = $script:NpxPath }
208
+ else { $npxDesc = "$($script:NpxPath) (unusable: node $(if ($script:NodeVer) { $script:NodeVer } else { 'missing' }) is below $NodeFloor)" }
209
+ }
210
+ Write-Output (" npx: {0}" -f $npxDesc)
182
211
  Write-Output (" bunx: {0}" -f $(if ($script:BunxPresent) { $script:BunxPath } else { 'no' }))
183
212
  Write-Output (" node: {0}" -f $(if ($script:NodePresent) { $script:NodeVer } else { 'no' }))
184
213
  Write-Output (" selected path: {0}" -f $script:Selected)
@@ -22,7 +22,7 @@ set -eu
22
22
  # package.json version, and the release script rewrites it on every bump.
23
23
  PKG="@liustack/modlens"
24
24
  BIN="modlens"
25
- PINNED="3.1.0"
25
+ PINNED="3.1.1"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
@@ -67,6 +67,23 @@ cli_version() {
67
67
  sed -n 's/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p'
68
68
  }
69
69
 
70
+ # The npx path runs the CLI on this machine's node, so npx is only usable when
71
+ # node itself meets the CLI's floor. An old node with a working npx used to be
72
+ # selected anyway, a path known to fail at run time.
73
+ NODE_FLOOR="22.13.0"
74
+ node_meets_floor() {
75
+ command -v node >/dev/null 2>&1 || return 1
76
+ _nv="$(node --version 2>/dev/null | sed 's/^v//')"
77
+ [ -n "$_nv" ] || return 1
78
+ parse_semver "$NODE_FLOOR"
79
+ _floor_maj="$_MAJ"
80
+ _floor_min="$_MIN"
81
+ parse_semver "$_nv"
82
+ if [ "$_MAJ" -gt "$_floor_maj" ]; then return 0; fi
83
+ if [ "$_MAJ" -lt "$_floor_maj" ]; then return 1; fi
84
+ [ "$_MIN" -ge "$_floor_min" ]
85
+ }
86
+
70
87
  # Echo exactly one word: the chosen launch path.
71
88
  resolve() {
72
89
  if command -v "$BIN" >/dev/null 2>&1; then
@@ -76,7 +93,7 @@ resolve() {
76
93
  return
77
94
  fi
78
95
  fi
79
- if command -v npx >/dev/null 2>&1; then
96
+ if command -v npx >/dev/null 2>&1 && node_meets_floor; then
80
97
  echo "npx"
81
98
  return
82
99
  fi
@@ -160,13 +177,20 @@ collect() {
160
177
  G_NODE_VER="$(node --version 2>/dev/null | sed 's/^v//')"
161
178
  fi
162
179
 
180
+ G_NODE_FLOOR_OK=0
181
+ if node_meets_floor; then G_NODE_FLOOR_OK=1; fi
182
+
163
183
  G_SEL="$(resolve)"
164
184
  }
165
185
 
166
186
  # Build the nextSteps JSON array body (without the brackets) into G_NEXTSTEPS.
167
187
  compute_next_steps() {
168
188
  if [ "$G_SEL" = "none" ]; then
169
- _s1="Install Node 22.13+ from https://nodejs.org so npx can run $PKG@$PINNED, then re-run this launcher."
189
+ if [ "$G_NPX_PRESENT" = 1 ] && [ "$G_NODE_FLOOR_OK" = 0 ]; then
190
+ _s1="npx is present but node ${G_NODE_VER:-missing} is below the $NODE_FLOOR floor this CLI needs. Upgrade Node at https://nodejs.org, then re-run this launcher."
191
+ else
192
+ _s1="Install Node 22.13+ from https://nodejs.org so npx can run $PKG@$PINNED, then re-run this launcher."
193
+ fi
170
194
  _s2="No JavaScript runtime? Install Bun from https://bun.sh to use bunx, or put a compatible $BIN (major ${PINNED%%.*}, at or above $PINNED) on PATH."
171
195
  G_NEXTSTEPS="$(printf '"%s", "%s"' "$(json_escape "$_s1")" "$(json_escape "$_s2")")"
172
196
  else
@@ -187,7 +211,7 @@ emit_json() {
187
211
  printf ' "checked": {\n'
188
212
  printf ' "pathCli": { "present": %s, "path": %s, "version": %s, "compatible": %s },\n' \
189
213
  "$(jbool "$G_CLI_PRESENT")" "$(jstr "$G_CLI_PATH")" "$(jstr "$G_CLI_VER")" "$(jbool "$G_CLI_COMPAT")"
190
- printf ' "npx": { "present": %s, "path": %s },\n' "$(jbool "$G_NPX_PRESENT")" "$(jstr "$G_NPX_PATH")"
214
+ printf ' "npx": { "present": %s, "path": %s, "nodeMeetsFloor": %s },\n' "$(jbool "$G_NPX_PRESENT")" "$(jstr "$G_NPX_PATH")" "$(jbool "$G_NODE_FLOOR_OK")"
191
215
  printf ' "bunx": { "present": %s, "path": %s },\n' "$(jbool "$G_BUNX_PRESENT")" "$(jstr "$G_BUNX_PATH")"
192
216
  printf ' "node": { "present": %s, "version": %s }\n' "$(jbool "$G_NODE_PRESENT")" "$(jstr "$G_NODE_VER")"
193
217
  printf ' },\n'
@@ -216,7 +240,15 @@ emit_text() {
216
240
  else
217
241
  printf ' %s on PATH: no\n' "$BIN"
218
242
  fi
219
- printf ' npx: %s\n' "$([ "$G_NPX_PRESENT" = 1 ] && echo "$G_NPX_PATH" || echo no)"
243
+ _npx_desc="no"
244
+ if [ "$G_NPX_PRESENT" = 1 ]; then
245
+ if [ "$G_NODE_FLOOR_OK" = 1 ]; then
246
+ _npx_desc="$G_NPX_PATH"
247
+ else
248
+ _npx_desc="$G_NPX_PATH (unusable: node ${G_NODE_VER:-missing} is below $NODE_FLOOR)"
249
+ fi
250
+ fi
251
+ printf ' npx: %s\n' "$_npx_desc"
220
252
  printf ' bunx: %s\n' "$([ "$G_BUNX_PRESENT" = 1 ] && echo "$G_BUNX_PATH" || echo no)"
221
253
  printf ' node: %s\n' "$([ "$G_NODE_PRESENT" = 1 ] && echo "${G_NODE_VER:-yes}" || echo no)"
222
254
  printf ' selected path: %s\n' "$G_SEL"