Skip to content

Resolve operator paths against the CWD, making uv tool install usable - #43

Merged
bandrel merged 6 commits into
mainfrom
fix/cwd-relative-paths
Aug 22, 2026
Merged

Resolve operator paths against the CWD, making uv tool install usable#43
bandrel merged 6 commits into
mainfrom
fix/cwd-relative-paths

Conversation

@bandrel

@bandrel bandrel commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Why

PR #42 made SpooNMAP installable — uv tool install git+https://github.com/trustedsec/spoonmap builds a self-contained wheel and resolves all 19 bundled NSE scripts from the isolated tool venv. But the installed tool was not yet usable, because spoonmap.py derived its operator directory from the module's own location:

dir_path = os.path.dirname(os.path.realpath(__file__))

Under uv tool install that is ~/.local/share/uv/tools/spoonmap/lib/python3.12/site-packages. And dir_path decided where config.json was read from, where exclusions.txt defaulted to, and — by default — where scan output was written. So an installed spoonmap wanted its config inside uv's managed tool environment and wrote engagement results there, into a directory that gets rebuilt whenever an upgrade installs a new version.

The root cause is that dir_path conflated where the program lives with where the operator's data lives. Those are the same directory in a checkout, which is why it went unnoticed. PR #42 already split out the program-data half (bundled NSE scripts moved to _NSE_DIR); this finishes the job.

What changed

Operator paths now resolve against the current working directory, via a single _operator_dir() helper. That covers config.json, exclusions.txt, the default output location, relative target_file / output_path / exclusions_file values inside config.json, and --cleanup.

The derivation was extracted out of main() deliberately: main() carries # pragma: no cover, so the one line that mattered was the one line no test could reach. It is now a tested helper.

_DIR and _NSE_DIR are untouched — bundled NSE scripts ship inside the wheel and must keep resolving from the module's location, or script_scan breaks on an installed tool. The __file__ anchor count went from two to one: the operator path no longer has any module anchor at all.

Also fixed: the two config-error messages printed a bare config.json.sample filename, which is unactionable for an installed user whose copy sits next to the module. They now print the resolved path.

No packaging or CI changes. pyproject.toml's only edit is a comment this branch falsified.

Verification

Proven against a real installed wheel rather than argued from source.

Runs from any directory, output stays local. Two unrelated directories, each with its own config.json ({"output_path": "results"}) and prior output:

$ cd engagement-a && spoonmap --cleanup
Scan data removed from .../engagement-a/results

$ ls engagement-b/results
all_live_hosts.txt   discovery      # untouched

Config loading follows the CWD. _load_config on the installed wheel, from an arbitrary directory:

_operator_dir() : .../engagement
  target_file      -> .../engagement/ranges.txt
  output_path      -> .../engagement/out
  exclusions_file  -> .../engagement/excl.txt
module lives at : .../v/lib/python3.12/site-packages
any resolved path inside the module dir? False

Nothing writes to _DIR: its only remaining uses are the NSE anchor, the two config.json.sample messages, and a docstring. Every os.makedirs sits under output_path.

Gate: 978 passed, 5 skipped, 100% coverage (floor 95%) · ruff clean · uv lock --check unaffected · bandit 32 baseline / 0 new. No suppressions, coverage floor untouched.

Behaviour change

Invoking by absolute path from elsewhere (cd /tmp && /opt/spoonmap/spoonmap.py) previously read /opt/spoonmap/config.json and wrote output under /opt/spoonmap/. It now uses /tmp. For the documented invocation — from inside the checkout — nothing changes, because the CWD and the checkout are the same directory.

This is disclosed in the README's new "Where Files Live" section, with a pointer from "Usage" so someone reads it before running rather than after wondering where their output went.

Docs

  • README gains an ## Installation section covering uv tool install git+... and uv tool upgrade, stating plainly that no PyPI package exists, and noting that installing does not remove the need for masscan and nmap.
  • README gains ## Where Files Live, documenting the CWD rule, the behaviour change, and the one exception — bundled NSE scripts, named in both forms (nse/ in a checkout, spoonmap_nse/ installed) so a reader can match it to what they have on disk.
  • CLAUDE.md gains ## Operator Path Resolution covering the same semantics and the _DIR/_NSE_DIR boundary.

🤖 Generated with Claude Code

bandrel and others added 6 commits August 21, 2026 17:59
dir_path was derived from os.path.dirname(os.path.realpath(__file__)),
which decides where config.json, exclusions.txt, and scan output
resolve. Under `uv tool install`, that directory is inside uv's
managed tool venv -- not a place an operator can put a config or would
want engagement results written, and one uv tool upgrade rebuilds from
scratch.

Extract the derivation into a module-level _operator_dir() helper
(os.getcwd()) so it is testable outside main()'s pragma-no-cover
region, and switch main()'s dir_path to it. _DIR/_NSE_DIR are left
untouched -- they anchor the bundled NSE scripts, which are program
data and must keep resolving from the module's own location regardless
of the caller's CWD. Also drop the vestigial `global dir_path` in
main(): nothing outside main() reads a module-level dir_path (both
_cleanup_cmd and _load_config take it as a parameter), unlike
output_path, whose global is genuinely read by tests via
spoonmap.output_path.

The two config.json.sample guidance messages now name the resolved
path (via _DIR) instead of a bare filename, since that file lives next
to the module -- nowhere near an installed user's CWD -- so a bare
name is unactionable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
config.json, exclusions.txt, default output, relative config values,
and --cleanup now resolve against the CWD the command was run from,
not the directory containing spoonmap.py. Document that plainly in
both README.md (a new "Where Files Live" section, plus an updated
config.json parameter table entry for output_path) and CLAUDE.md (a
"Operator path resolution" note tied to _operator_dir()), including an
explicit call-out that invoking by absolute/relative path from another
directory now behaves differently than before, and the one exception:
bundled NSE scripts under nse/ are program data and keep resolving
from the module's own location (_DIR/_NSE_DIR) regardless of CWD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The docstring and a test comment justified CWD-vs-_DIR by citing
`uv tool install`'s managed venv, which cites a packaging mechanism
this project has not committed to shipping. Reworded to the
install-agnostic reasoning already used in README.md: operator data
belongs where the command was run from, not wherever the module
happens to be installed -- true regardless of install mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Packaging decision is resolved -- the wheel stays exactly as built by
pyproject.toml's hatch config, so the earlier hold on documenting the
install path is lifted. Adds an "Installation" section to README.md
covering `uv tool install git+https://github.com/trustedsec/spoonmap`
(no PyPI package involved -- naming just `spoonmap` will not work),
`uv tool upgrade`, and an explicit note that masscan/nmap still need
separate installation either way. Cross-references "Where Files Live"
rather than restating it, since an installed spoonmap is the case
where the CWD rule matters most.

CLAUDE.md gets one paragraph noting the wheel is a supported
consumption path contributors must keep working (config.json.sample
and nse/ landing in the wheel, per the build CI job) -- no end-user
walkthrough, since that file is contributor-facing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dings

- _operator_dir() docstring: restore the concrete hazard the round-1
  override stripped. The packaging decision has since landed on
  keeping the wheel, so the install-agnostic wording was no longer the
  right call — an installed spoonmap's module directory lives inside
  uv's managed tool environment, which `uv tool upgrade` rebuilds from
  scratch, destroying anything stored there. That's what actually
  makes CWD the only defensible choice.
- pyproject.toml's force-include comment and _load_config's docstring
  both described dir_path as script-relative, which this branch made
  false; reworded to describe the operator directory and _DIR
  correctly (config.json.sample ships at _DIR because that's where the
  error messages point, not because config.json itself lives there).
- CLAUDE.md: "Operator path resolution" was a level-3 heading nested
  under "Running the Tool" with nothing else at that level between it
  and "Architecture", so ~135 lines of CI/test documentation read as
  nested under a path-resolution subsection. Moved it to its own
  level-2 "Operator Path Resolution" section between "Running the
  Tool" and "Architecture".
- README: name both nse/ (checkout) and spoonmap_nse/ (installed
  wheel) as the NSE directory forms, since the Installation section
  now points installed users at this same paragraph. Soften the PyPI
  guidance to not diagnose a future `uv tool install spoonmap` failure
  as user error, in case the name is ever claimed on PyPI. Add a
  one-line forward reference from Usage to "Where Files Live".
- tests/test_spoonmap.py: TestOperatorDirResolution's CWD comparisons
  now compare os.path.realpath() on both sides instead of raw
  str(tmp_path), since the prior form only passed because pytest
  happens to hand out an already-resolved tmp_path (macOS's /tmp ->
  /private/tmp makes this fragile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"uv tool upgrade rebuilds from scratch" read as though any invocation
destroys the module directory. Verified directly: `uv tool upgrade`
with nothing to upgrade prints "Nothing to upgrade" and leaves planted
sentinel files intact; only an invocation that actually installs a new
version (equivalent to `uv tool install --force`) rebuilds the
environment and loses them. Reworded to "rebuilt whenever uv tool
upgrade actually installs a new version" so the comment doesn't die to
someone testing a no-op upgrade and concluding the rationale is wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bandrel
bandrel merged commit 3a15ab2 into main Aug 22, 2026
12 checks passed
@bandrel
bandrel deleted the fix/cwd-relative-paths branch August 22, 2026 01:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant