@miller-tech/uap 1.61.2 → 1.62.0

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.
@@ -20,20 +20,13 @@ export interface NormalizeResult {
20
20
  /** Why it was changed, or null if unchanged. */
21
21
  reason: string | null;
22
22
  }
23
- export interface NormalizeOptions {
24
- /**
25
- * Max Levenshtein distance (on the basename) for the typo fallback to fire.
26
- * Defaults to max(2, 30% of basename length).
27
- */
28
- maxEditDistance?: number;
29
- }
30
23
  /**
31
24
  * Snap `proposed` to the nearest real file in `workdirFiles` (paths relative to
32
25
  * the workdir). Returns `proposed` unchanged if it already resolves or no
33
26
  * confident match exists — the normalizer never invents a target, so a genuinely
34
27
  * new file (a legitimate create) is left alone.
35
28
  */
36
- export declare function normalizeToolPath(proposed: string, workdirFiles: string[], opts?: NormalizeOptions): NormalizeResult;
29
+ export declare function normalizeToolPath(proposed: string, workdirFiles: string[]): NormalizeResult;
37
30
  /** Tool-call argument keys that carry a file path, by Claude Code tool name. */
38
31
  export declare const PATH_ARG_KEYS: readonly ["file_path", "path", "filePath", "notebook_path"];
39
32
  /**
@@ -41,7 +34,7 @@ export declare const PATH_ARG_KEYS: readonly ["file_path", "path", "filePath", "
41
34
  * Returns the corrections made (empty when nothing changed). Pure w.r.t. the
42
35
  * filesystem — `workdirFiles` is supplied by the caller (the hook).
43
36
  */
44
- export declare function normalizeToolInput(input: Record<string, unknown>, workdirFiles: string[], opts?: NormalizeOptions): {
37
+ export declare function normalizeToolInput(input: Record<string, unknown>, workdirFiles: string[]): {
45
38
  input: Record<string, unknown>;
46
39
  corrections: {
47
40
  key: string;
@@ -1 +1 @@
1
- {"version":3,"file":"path-normalizer.d.ts","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAkDD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EAAE,EACtB,IAAI,GAAE,gBAAqB,GAC1B,eAAe,CAmDjB;AAED,gFAAgF;AAChF,eAAO,MAAM,aAAa,6DAA8D,CAAC;AAEzF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,YAAY,EAAE,MAAM,EAAE,EACtB,IAAI,GAAE,gBAAqB,GAC1B;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,WAAW,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAa9G"}
1
+ {"version":3,"file":"path-normalizer.d.ts","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAkCD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EAAE,GACrB,eAAe,CAwCjB;AAED,gFAAgF;AAChF,eAAO,MAAM,aAAa,6DAA8D,CAAC;AAEzF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,YAAY,EAAE,MAAM,EAAE,GACrB;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,WAAW,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAa9G"}
@@ -36,30 +36,11 @@ function parentDir(p) {
36
36
  function dirCompatible(proposed, candidate) {
37
37
  const cp = parentDir(candidate);
38
38
  if (cp === '')
39
- return true; // bare known path — safe to snap to
40
- return squash(parentDir(proposed)) === squash(cp);
41
- }
42
- function editDistance(a, b) {
43
- const m = a.length;
44
- const n = b.length;
45
- if (m === 0)
46
- return n;
47
- if (n === 0)
48
- return m;
49
- const prev = new Array(n + 1);
50
- const cur = new Array(n + 1);
51
- for (let j = 0; j <= n; j++)
52
- prev[j] = j;
53
- for (let i = 1; i <= m; i++) {
54
- cur[0] = i;
55
- for (let j = 1; j <= n; j++) {
56
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
57
- cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
58
- }
59
- for (let j = 0; j <= n; j++)
60
- prev[j] = cur[j];
61
- }
62
- return prev[n];
39
+ return true; // bare workdir-relative file — safe to snap to
40
+ // EXACT parent match only. A previous version compared squash(parent), which
41
+ // collapsed punctuation/case and let writes relocate between genuinely
42
+ // different directories (e.g. `s-space-shooter` vs `s space-shooter`).
43
+ return parentDir(proposed) === cp;
63
44
  }
64
45
  /**
65
46
  * Snap `proposed` to the nearest real file in `workdirFiles` (paths relative to
@@ -67,7 +48,7 @@ function editDistance(a, b) {
67
48
  * confident match exists — the normalizer never invents a target, so a genuinely
68
49
  * new file (a legitimate create) is left alone.
69
50
  */
70
- export function normalizeToolPath(proposed, workdirFiles, opts = {}) {
51
+ export function normalizeToolPath(proposed, workdirFiles) {
71
52
  const unchanged = { path: proposed, changed: false, reason: null };
72
53
  if (!proposed || workdirFiles.length === 0)
73
54
  return unchanged;
@@ -98,17 +79,9 @@ export function normalizeToolPath(proposed, workdirFiles, opts = {}) {
98
79
  hit = unique(safe(workdirFiles.filter((f) => squash(basename(f)) === sb)));
99
80
  if (hit)
100
81
  return snap(hit, 'extension/punctuation-normalized to the real filename');
101
- // 4) Edit-distance fallback for genuine typos. Fire only when the best match
102
- // is meaningfully better than the runner-up (avoids ambiguous snaps).
103
- const maxD = opts.maxEditDistance ?? Math.max(2, Math.floor(base.length * 0.3));
104
- const ranked = safe(workdirFiles)
105
- .map((f) => ({ f, d: editDistance(base.toLowerCase(), basename(f).toLowerCase()) }))
106
- .sort((x, y) => x.d - y.d);
107
- if (ranked.length > 0 &&
108
- ranked[0].d <= maxD &&
109
- (ranked.length < 2 || ranked[1].d > ranked[0].d)) {
110
- return snap(ranked[0].f, `nearest filename by edit distance (${ranked[0].d})`);
111
- }
82
+ // No edit-distance fallback: guessing the "nearest" filename produced wrong
83
+ // snaps (e.g. `oct` -> `octop`). A garble we cannot resolve by exact basename /
84
+ // case / punctuation match is left unchanged so it fails loud and self-corrects.
112
85
  return unchanged;
113
86
  }
114
87
  /** Tool-call argument keys that carry a file path, by Claude Code tool name. */
@@ -118,14 +91,14 @@ export const PATH_ARG_KEYS = ['file_path', 'path', 'filePath', 'notebook_path'];
118
91
  * Returns the corrections made (empty when nothing changed). Pure w.r.t. the
119
92
  * filesystem — `workdirFiles` is supplied by the caller (the hook).
120
93
  */
121
- export function normalizeToolInput(input, workdirFiles, opts = {}) {
94
+ export function normalizeToolInput(input, workdirFiles) {
122
95
  const corrections = [];
123
96
  const out = { ...input };
124
97
  for (const key of PATH_ARG_KEYS) {
125
98
  const v = out[key];
126
99
  if (typeof v !== 'string')
127
100
  continue;
128
- const r = normalizeToolPath(v, workdirFiles, opts);
101
+ const r = normalizeToolPath(v, workdirFiles);
129
102
  if (r.changed) {
130
103
  out[key] = r.path;
131
104
  corrections.push({ key, from: v, to: r.path, reason: r.reason ?? '' });
@@ -1 +1 @@
1
- {"version":3,"file":"path-normalizer.js","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"path-normalizer.js","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.61.2",
3
+ "version": "1.62.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """workdir-scope enforcer: file-mutating tool calls must stay within the project
3
+ working directory (the repo root + its worktrees + a small scratch allow-list).
4
+
5
+ Creating/writing/moving a path OUTSIDE the workdir is blocked by default — it
6
+ requires explicit operator approval. This is the policy-engine enforcement of the
7
+ operator rule "never step outside the current path without explicit permission":
8
+ agents running with --dangerously-skip-permissions emit absolute paths that can
9
+ escape the project (e.g. a sibling at ~/dev, or a garbled `octopusspace-shooter`),
10
+ silently creating directories outside the intended workspace.
11
+
12
+ Allowed targets:
13
+ * anything under the current working tree (UAP_WORKTREE_ROOT) or the main
14
+ checkout (UAP_REPO_ROOT) — worktrees included;
15
+ * relative paths (they resolve under the project root);
16
+ * a scratch allow-list: /tmp, $TMPDIR, ~/.cache/uap, ~/.config/uap, plus any
17
+ colon-separated prefixes in UAP_WORKDIR_ALLOW.
18
+
19
+ Escape hatch: UAP_WORKDIR_SCOPE_OFF=1 allows everything (operator override).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ import shlex
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ sys.path.insert(0, str(Path(__file__).parent))
30
+ from _common import emit, parse_cli, repo_root, worktree_root # noqa: E402
31
+
32
+ # Tools that create/modify a file at an explicit path argument.
33
+ PATH_WRITE_OPS = {
34
+ "Write", "Edit", "MultiEdit", "NotebookEdit",
35
+ "write", "edit", "multiedit", "notebookedit",
36
+ }
37
+ PATH_ARG_KEYS = ("file_path", "path", "notebook_path", "filePath", "target")
38
+
39
+ # Bash verbs that CREATE/MOVE filesystem entries (pure-read commands are ignored).
40
+ _BASH_CREATE = ("mkdir", "touch", "install", "tee")
41
+ _BASH_DEST_LAST = ("cp", "mv", "rsync") # destination is the final argument
42
+
43
+
44
+ def _expand(p: str) -> Path:
45
+ return Path(os.path.expanduser(os.path.expandvars(p)))
46
+
47
+
48
+ def _allowed_roots() -> list[Path]:
49
+ roots: list[Path] = []
50
+
51
+ def add(p: Path) -> None:
52
+ try:
53
+ r = p.resolve()
54
+ except Exception: # noqa: BLE001
55
+ r = p
56
+ if r not in roots:
57
+ roots.append(r)
58
+
59
+ add(worktree_root())
60
+ add(repo_root())
61
+ for p in ("/tmp", os.environ.get("TMPDIR", "/tmp"), "~/.cache/uap", "~/.config/uap"):
62
+ add(_expand(p))
63
+ for p in os.environ.get("UAP_WORKDIR_ALLOW", "").split(":"):
64
+ if p.strip():
65
+ add(_expand(p))
66
+ return roots
67
+
68
+
69
+ def _inside(target: Path, roots: list[Path]) -> bool:
70
+ try:
71
+ t = target.resolve()
72
+ except Exception: # noqa: BLE001
73
+ t = target
74
+ for root in roots:
75
+ try:
76
+ t.relative_to(root)
77
+ return True
78
+ except ValueError:
79
+ continue
80
+ return False
81
+
82
+
83
+ def _check_path(target: str, roots: list[Path]) -> str:
84
+ """Return the offending absolute path if out of scope, else ''."""
85
+ if not target:
86
+ return ""
87
+ p = _expand(target)
88
+ if not p.is_absolute():
89
+ # Relative paths resolve under the enforcer cwd (the project root).
90
+ return ""
91
+ return "" if _inside(p, roots) else str(p)
92
+
93
+
94
+ def _scan_bash(cmd: str, roots: list[Path]) -> str:
95
+ """Best-effort: flag an out-of-scope absolute path that a CREATE/MOVE command
96
+ would write. Conservative — only inspects the destinations of known
97
+ create/move verbs and output redirections, and ignores read sources."""
98
+ if not cmd:
99
+ return ""
100
+ try:
101
+ tokens = shlex.split(cmd, comments=True)
102
+ except ValueError:
103
+ tokens = cmd.split()
104
+
105
+ candidates: list[str] = []
106
+
107
+ # Output redirections: > /abs, >> /abs (also 2>/abs).
108
+ for m in re.finditer(r'(?:\d*>>?|&>)\s*("?)(/[^\s"\';|&)]+)\1', cmd):
109
+ candidates.append(m.group(2))
110
+
111
+ # Split into pipeline/sequence segments so we read each command's own verb.
112
+ segments = re.split(r'\|\||&&|[;|&\n]', cmd)
113
+ for seg in segments:
114
+ try:
115
+ parts = shlex.split(seg, comments=True)
116
+ except ValueError:
117
+ parts = seg.split()
118
+ if not parts:
119
+ continue
120
+ verb = os.path.basename(parts[0])
121
+ argv = [a for a in parts[1:] if not a.startswith("-")]
122
+ if verb in _BASH_CREATE:
123
+ candidates.extend(argv) # all targets are created
124
+ elif verb in _BASH_DEST_LAST and argv:
125
+ candidates.append(argv[-1]) # only the destination is written
126
+
127
+ for c in candidates:
128
+ bad = _check_path(c, roots)
129
+ if bad:
130
+ return bad
131
+ return ""
132
+
133
+
134
+ def main() -> None:
135
+ op, args = parse_cli()
136
+
137
+ if os.environ.get("UAP_WORKDIR_SCOPE_OFF") == "1":
138
+ emit(True, "UAP_WORKDIR_SCOPE_OFF override set")
139
+
140
+ roots = _allowed_roots()
141
+
142
+ if op in PATH_WRITE_OPS:
143
+ for key in PATH_ARG_KEYS:
144
+ bad = _check_path(args.get(key) or "", roots)
145
+ if bad:
146
+ _deny(bad)
147
+ emit(True, "target within workdir scope")
148
+
149
+ if op in {"Bash", "bash"}:
150
+ bad = _scan_bash(args.get("command") or "", roots)
151
+ if bad:
152
+ _deny(bad, bash=True)
153
+ emit(True, "no out-of-scope write target in command")
154
+
155
+ emit(True, "not a path-mutating operation")
156
+
157
+
158
+ def _deny(path: str, bash: bool = False) -> None:
159
+ where = "command writes to" if bash else "target"
160
+ emit(
161
+ False,
162
+ f"workdir-scope: {where} '{path}' is OUTSIDE the project working directory. "
163
+ "Stepping outside the current path requires explicit permission. "
164
+ "Write inside the project, or — if this is intended — re-run with "
165
+ "UAP_WORKDIR_SCOPE_OFF=1 (or add the prefix to UAP_WORKDIR_ALLOW).",
166
+ )
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
@@ -0,0 +1,38 @@
1
+ # workdir-scope
2
+
3
+ **Category**: safety
4
+ **Level**: REQUIRED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: filesystem, scope, safety, permission
7
+
8
+ ## Rule
9
+
10
+ File-mutating tool calls MUST stay within the project working directory. A
11
+ `Write`, `Edit`, `MultiEdit`, or `NotebookEdit` whose target — or a `Bash`
12
+ command whose create/move destination (`mkdir`, `touch`, `cp`, `mv`, `install`,
13
+ `tee`, output redirection) — resolves OUTSIDE the working tree is **blocked**.
14
+
15
+ In scope (allowed): the current working tree and main checkout (worktrees
16
+ included), relative paths, and a scratch allow-list (`/tmp`, `$TMPDIR`,
17
+ `~/.cache/uap`, `~/.config/uap`, plus `UAP_WORKDIR_ALLOW` prefixes).
18
+
19
+ ## Why
20
+
21
+ Agents running with `--dangerously-skip-permissions` emit absolute paths that can
22
+ escape the project (a sibling at `~/dev`, or a garbled name like
23
+ `octopusspace-shooter`), silently creating directories and writing files outside
24
+ the intended workspace. Stepping outside the current path must require explicit
25
+ operator permission — not happen silently.
26
+
27
+ ## Enforcement
28
+
29
+ Python enforcer `workdir_scope.py` resolves each target/destination against the
30
+ working-tree roots and rejects any that fall outside (minus the scratch
31
+ allow-list). Escape hatch: `UAP_WORKDIR_SCOPE_OFF=1` allows everything;
32
+ `UAP_WORKDIR_ALLOW=/extra/prefix:...` widens the allow-list.
33
+
34
+ ```rules
35
+ - title: "File writes must stay inside the project working directory"
36
+ keywords: [write, edit, multiedit, notebookedit, mkdir, create-file, bash]
37
+ antiPatterns: []
38
+ ```
@@ -15,6 +15,7 @@ TS reference at src/self-harness/middleware/path-normalizer.ts.
15
15
  See docs/design/SELF_HARNESS.md §4 (P2).
16
16
  """
17
17
 
18
+ import os
18
19
  import re
19
20
 
20
21
  _PATH_ARG_KEYS = ("file_path", "path", "filePath", "notebook_path")
@@ -24,92 +25,77 @@ def _squash(s: str) -> str:
24
25
  return re.sub(r"[^a-z0-9]", "", s.lower())
25
26
 
26
27
 
27
- def _basename(p: str) -> str:
28
- parts = [x for x in p.split("/") if x]
29
- return parts[-1] if parts else p
30
-
28
+ def normalize_tool_path(proposed: str, known_paths=None, max_edit_distance=None):
29
+ """Conservatively correct a garbled file path by snapping ONLY the filename to
30
+ a real sibling that already exists in the SAME, on-disk directory. Returns
31
+ ``(path, changed, reason)``.
32
+
33
+ Hardened (2026-06-24) after the heuristic version silently RELOCATED writes
34
+ across projects/worktrees (e.g. ``octopus_invaders/js/config.js`` ->
35
+ ``octopus-invader/space-shooter/js/config.js``), turning a loud,
36
+ self-correcting "no such file" failure into a silent wrong-write that
37
+ clobbered unrelated files. The rules below make a wrong-directory snap
38
+ structurally impossible:
39
+
40
+ * absolute paths only — the proxy cannot verify a relative path's cwd;
41
+ * never touch a path that already exists (it is correct);
42
+ * the directory must already exist on disk and is used VERBATIM — we never
43
+ alter, squash, case-fold or cross a directory segment, so a garbled
44
+ directory simply fails loud (as it did before the normalizer existed);
45
+ * snap only when EXACTLY ONE real file in that directory matches the
46
+ basename case-insensitively or punctuation/extension-squashed;
47
+ * no edit-distance guessing.
48
+
49
+ ``known_paths`` is accepted for signature/back-compat but the on-disk
50
+ directory is the source of truth. Anything that cannot be verified on disk is
51
+ returned unchanged (fail safe to a clean failure).
52
+ """
53
+ if not proposed:
54
+ return proposed, False, None
31
55
 
32
- def _parent_dir(p: str) -> str:
33
- parts = [x for x in p.split("/") if x]
34
- return "/".join(parts[:-1])
56
+ trimmed = proposed.strip()
35
57
 
58
+ # Absolute paths only: the proxy has no reliable cwd for relative paths.
59
+ if not os.path.isabs(trimmed):
60
+ return proposed, False, None
36
61
 
37
- def _dir_compatible(proposed: str, candidate: str) -> bool:
38
- """Only fix the FILENAME / strip a wrong abs prefix — never RELOCATE across
39
- structurally-different directory trees. Bare (no-parent) candidates are safe;
40
- otherwise the parent dirs must match. Blocks the observed harm of snapping
41
- octopus_invaders/js/config.js to a different known dir."""
42
- cp = _parent_dir(candidate)
43
- if cp == "":
44
- return True
45
- return _squash(_parent_dir(proposed)) == _squash(cp)
62
+ # Already real? Nothing to fix (covers files, directories, trailing slashes).
63
+ if os.path.exists(trimmed):
64
+ if trimmed != proposed:
65
+ return trimmed, True, "trimmed surrounding whitespace"
66
+ return proposed, False, None
46
67
 
68
+ parent = os.path.dirname(trimmed)
69
+ base = os.path.basename(trimmed)
70
+ # A wrong/garbled DIRECTORY must never be guessed — only fix the filename
71
+ # within a directory that provably exists.
72
+ if not base or not os.path.isdir(parent):
73
+ return proposed, False, None
47
74
 
48
- def _edit_distance(a: str, b: str) -> int:
49
- m, n = len(a), len(b)
50
- if m == 0:
51
- return n
52
- if n == 0:
53
- return m
54
- prev = list(range(n + 1))
55
- for i in range(1, m + 1):
56
- cur = [i] + [0] * n
57
- for j in range(1, n + 1):
58
- cost = 0 if a[i - 1] == b[j - 1] else 1
59
- cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
60
- prev = cur
61
- return prev[n]
75
+ try:
76
+ entries = [
77
+ e for e in os.listdir(parent)
78
+ if os.path.isfile(os.path.join(parent, e))
79
+ ]
80
+ except OSError:
81
+ return proposed, False, None
62
82
 
83
+ # Same-directory basename repair only.
84
+ matches = [e for e in entries if e.lower() == base.lower()]
85
+ reason = "case-normalized to the real filename in the same directory"
86
+ if not matches:
87
+ sb = _squash(base)
88
+ matches = [e for e in entries if _squash(e) == sb]
89
+ reason = "extension/punctuation-normalized to the real filename in the same directory"
63
90
 
64
- def normalize_tool_path(proposed: str, known_paths, max_edit_distance=None):
65
- """Snap `proposed` to the nearest path in `known_paths` (paths the model used
66
- correctly earlier). Returns (path, changed, reason). Never invents a target:
67
- an unmatched path (a legitimate new file) is returned unchanged.
68
- """
69
- known = list(dict.fromkeys(known_paths)) # de-dupe, preserve order
70
- if not proposed or not known:
91
+ # 0 matches = nothing to snap to; >1 = ambiguous -> leave alone (fail safe).
92
+ if len(matches) != 1:
71
93
  return proposed, False, None
72
94
 
73
- trimmed = proposed.strip()
74
- if trimmed in known:
75
- if trimmed != proposed:
76
- return trimmed, True, "trimmed surrounding whitespace"
95
+ target = os.path.join(parent, matches[0])
96
+ if target == proposed:
77
97
  return proposed, False, None
78
-
79
- base = _basename(trimmed)
80
-
81
- def snap(target, reason):
82
- return (target, True, reason) if target != proposed else (proposed, False, None)
83
-
84
- def unique(cands):
85
- return cands[0] if len(cands) == 1 else None
86
-
87
- def safe(cands):
88
- return [k for k in cands if _dir_compatible(trimmed, k)]
89
-
90
- # 1) exact basename (stray dirs)
91
- hit = unique(safe([k for k in known if _basename(k) == base]))
92
- if hit:
93
- return snap(hit, "stray path components removed")
94
- # 2) case-insensitive basename
95
- hit = unique(safe([k for k in known if _basename(k).lower() == base.lower()]))
96
- if hit:
97
- return snap(hit, "case-normalized to the real filename")
98
- # 3) punctuation/extension-squashed basename
99
- sb = _squash(base)
100
- hit = unique(safe([k for k in known if _squash(_basename(k)) == sb]))
101
- if hit:
102
- return snap(hit, "extension/punctuation-normalized to the real filename")
103
- # 4) edit-distance fallback (only when clearly closest)
104
- max_d = max_edit_distance if max_edit_distance is not None else max(2, len(base) // 3)
105
- ranked = sorted(
106
- ((k, _edit_distance(base.lower(), _basename(k).lower())) for k in safe(known)),
107
- key=lambda kv: kv[1],
108
- )
109
- if ranked and ranked[0][1] <= max_d and (len(ranked) < 2 or ranked[1][1] > ranked[0][1]):
110
- return snap(ranked[0][0], f"nearest filename by edit distance ({ranked[0][1]})")
111
-
112
- return proposed, False, None
98
+ return target, True, reason
113
99
 
114
100
 
115
101
  def extract_known_paths(anthropic_messages) -> list: