Skip to content

Commit 00accd4

Browse files
TheWayWithinclaude
andcommitted
feat(import): 1Password metadata-only pull + mapper (EF-ISS-8 P1)
scripts/import-1password.sh runs two read-only `op` calls (vault list, item list), holds the result in memory, and pipes it through scripts/map-1password.py — a pure JSON-in/JSON-out mapper that reads five fields per item (title, category, urls, vault, updated_at) and drops everything else, including additional_information's username/email hints. Failures exit with their own codes and plain-English advice: 3 op absent, 4 no signed-in account, 5 refused, 6 no python3. A failed pull prints nothing rather than a truncated candidate list. Tests: stub `op` + fixtures + 38 assertions (mapping, ranking, dedup, wrapper failure modes, imported entries validating clean on both tiers), plus a literal grep guard so unmasking flags and per-item fetches can never appear in the repo. Suite 146 pass / 0 fail, shellcheck clean. Verified twice against the real 563-item vault, which found three defects fixtures would not have: substring keyword matching ranked "Readwise" as finance; a finance-looking secure note outranked real accounts; and 178 of 563 items share a title with another item, so they would share a pointer and re-import would hide the twin (now flagged as title_collisions for P2's wizard to resolve). Spec corrections recorded in ideation/1password-import-spec.md §11: the "1Password > {title}" pointer belongs in access_pointer, not identifier (which the owner edits with a last-4, breaking dedup), and priority uses the schema's `normal`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0b4818b commit 00accd4

10 files changed

Lines changed: 1078 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,26 @@ All specs live in `ideation/` (moved from repo root, 17 Jul 2026):
3939
- **EF-ISS-8 (open, project): 1Password import.** Spec committed at
4040
`ideation/1password-import-spec.md` — metadata-only `op` CLI pull, wizard
4141
+ bulk triage in the browser editor, dedup for review-time re-import.
42-
**P0 spike DONE 29 Jul** against the real vault (findings in spec §9:
43-
list output is clean of secrets; drop `additional_information`; 94% of a
44-
real vault is generic Login so ranking needs the finance heuristic).
45-
Owner machine ready: op 2.35.0 + 1Password 8 CLI integration on.
46-
- **Pickup sequence (agreed 29 Jul 2026):**
47-
1. Build EF-ISS-8 **P1**`scripts/import-1password.sh` (pull + mapper,
48-
candidate JSON, stub-`op` CI tests; spec §6-7).
49-
2. **P2** — editor integration: /import endpoint, vault picker, wizard
50-
with filter box + bulk mode, seal-time honesty gate.
42+
**P0 spike DONE 29 Jul** against the real vault (spec §9: list output is
43+
clean of secrets; drop `additional_information`; 94% of a real vault is
44+
generic Login so ranking needs the finance heuristic).
45+
**P1 BUILT 29 Jul** (spec §11): `scripts/import-1password.sh` (pull,
46+
in-memory, exit codes 3/4/5/6 for op-absent / no-account / refused /
47+
no-python) + `scripts/map-1password.py` (pure mapper → candidate JSON),
48+
`tests/stub-op/op` + fixtures, 38 tests, suite 146 pass / 0 fail, plus
49+
the metadata-only grep guard. Verified twice against the real 563-item
50+
vault. Spec corrections made in P1: the `1Password > {title}` pointer
51+
lives in **`access_pointer`** (not `identifier`), priority uses the
52+
schema's `normal`, and `title_collisions` flags the 178-of-563 items
53+
that share a title with another item.
54+
- **Pickup sequence (agreed 29 Jul 2026, P1 now done):**
55+
1. ~~EF-ISS-8 **P1** — pull + mapper + stub-`op` tests.~~ DONE 29 Jul.
56+
2. **P2 (next)** — editor integration: `GET /import/1password` in
57+
edit-server.py, vault picker, wizard with filter box + bulk mode,
58+
seal-time honesty gate. Must handle `title_collisions` (ask for a
59+
last-4 in `identifier`) and default the wizard to `default_include`
60+
with a "show everything" toggle. The importer needs no re-pull for
61+
the toggle — hidden candidates are already in the payload.
5162
3. **T-159 Session A** — Jamie builds his REAL register, using the
5263
import wizard as its first genuine UAT (dogfood on 563 items).
5364
4. **Session B** (docs/UAT-PLAN.md) — seal + print, PLUS the 1Password

ideation/1password-import-spec.md

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
categories, and URLs only, never secrets — then triage in the browser editor,
55
either one item at a time (wizard) or all at once (bulk), before sealing.
66

7-
Status: SPEC. Written 2026-07-28. Owner: Jamie. Register row: EF-ISS-8.
7+
Status: **P1 BUILT 2026-07-29** (pull + mapper + tests; findings in §11).
8+
P2-P4 still spec. Written 2026-07-28. Owner: Jamie. Register row: EF-ISS-8.
89

910
---
1011

@@ -126,8 +127,18 @@ Every field is a *guess presented for confirmation*, pre-filled never
126127
auto-final in wizard mode. Other mapped fields:
127128

128129
- `provider` ← item title
129-
- `identifier``1Password > {title}` — the pointer style the design wants;
130-
never a number, never a URL with query strings
130+
- `access_pointer``1Password > {title}` — the pointer style the design
131+
wants (and the exact example the editor already shows for this field).
132+
**Corrected in P1:** the first draft of this spec put the pointer in
133+
`identifier`, which is the field for a last-4 or a reference; the pointer
134+
belongs in `access_pointer`, and dedup (§5) is stronger there because the
135+
owner edits `identifier` (adding last-4 digits) and would break the key.
136+
- `identifier` ← the URL host if the item has one, else the title. A
137+
searchable reference, never a number, never a path or query string.
138+
- `priority` ← per the table; the schema's middle value is `normal`, not
139+
"medium". A finance heuristic hit (§9.5) raises it to `high`.
140+
- `ownership``sole` (the commonest case, and a required field — the
141+
wizard shows it for correction)
131142
- `status``active`; `last_confirmed` ← today (the owner is looking at it
132143
right now)
133144
- `action_notes` ← wizard: owner-written or a sensible template; bulk: the
@@ -138,26 +149,49 @@ not the engine — see the finance-heuristic ranking consequence there.
138149

139150
## 5. Dedup contract
140151

141-
- Match key: normalised `identifier` equal to `1Password > {title}`
152+
- Match key: normalised `access_pointer` equal to `1Password > {title}`
142153
(case-insensitive, whitespace-collapsed). Already-present matches are
143154
filtered out of the candidate list before either mode shows anything.
155+
The importer also matches the key against existing `identifier` values,
156+
so registers written before this feature still dedup.
144157
- Soft warning on near-misses: candidate title ≈ existing `provider`
145158
(exact match after normalisation) shows "possibly already listed as A007"
146159
instead of silently duplicating.
160+
- **Title clashes (P1 finding, §11):** two vault items can carry the same
161+
title, and then they carry the same pointer — so next year's re-import
162+
would hide the second one. The mapper flags those candidates with
163+
`title_collisions: N`; the wizard (P2) must ask for something
164+
distinguishing in `identifier` (last 4 digits) rather than silently
165+
accepting twins. On a real vault this is not an edge case: 178 of 563.
147166
- Dedup is what makes the review-time re-import (§3) work with zero extra
148167
machinery.
149168

150169
## 6. Architecture
151170

152171
Three small pieces, following the existing edit-server pattern:
153172

154-
1. **`scripts/import-1password.sh`** — the pull. Checks `op` exists and is
155-
signed in, runs `op item list --format=json` (plus `op vault list` for the
156-
picker), maps to a manager-agnostic **candidate JSON** on stdout:
157-
`{source, pulled, vaults:[...], candidates:[{title, category, url_host,
158-
vault, suggested:{provider, type, identifier, priority, status,
159-
last_confirmed, preferred_action, action_notes}}]}`.
160-
Pure, deterministic given `op` output → trivially testable with a stub.
173+
1. **`scripts/import-1password.sh`** — the pull (BUILT). Checks `op` exists
174+
and is signed in, runs `op vault list --format=json` for the picker and
175+
`op item list --format=json` for the items, and pipes the result through
176+
the mapper to stdout. Nothing touches disk: the pull is held in memory,
177+
and a failed pull prints nothing rather than a truncated list. Exit
178+
codes carry the failure mode: 3 = op absent, 4 = no signed-in account,
179+
5 = the pull was refused, 6 = python3 missing.
180+
**`scripts/map-1password.py`** — the mapping (BUILT), split out because
181+
it is pure: JSON in, JSON out, no `op`, no network, so the whole mapping
182+
contract is testable from a fixture. It reads exactly five fields per
183+
item (title, category, urls, vault, updated_at) and drops the rest,
184+
including `additional_information`.
185+
The **candidate JSON** it emits:
186+
`{source, pulled, vaults:[{name, items}], counts:{items, candidates,
187+
shown_by_default, hidden, deduped, other_vaults}, candidates:[{title,
188+
category, url_host, vault, updated, rank, default_include,
189+
possible_duplicate_of?, title_collisions?, suggested:{provider, type,
190+
identifier, priority, ownership, status, last_confirmed,
191+
preferred_action, action_notes, access_pointer}}]}`.
192+
`rank` is the money-first ordering (0-3 accounts, 4-5 non-accounts) and
193+
`default_include` is the "show everything" toggle — non-accounts are
194+
emitted, not withheld, so the toggle needs no second pull.
161195
The manager-agnostic shape is deliberate: a future Bitwarden importer
162196
(`bw list items`) plugs in behind the same contract (parked, not built).
163197
2. **`web/edit-server.py`** — one new endpoint, `GET /import/1password`
@@ -179,17 +213,22 @@ the tail skippable).
179213
## 7. Build phases
180214

181215
- **P0 — empirical spike. DONE 2026-07-29, findings in §9.**
182-
- **P1 — pull + mapping.** `import-1password.sh`, candidate JSON contract,
183-
stub-`op` CI fixture, mapping + dedup unit tests. Drops
184-
`additional_information`, adds the finance-heuristic ranker (§9).
216+
- **P1 — pull + mapping. DONE 2026-07-29**, findings in §11.
217+
`import-1password.sh` + `map-1password.py`, candidate JSON contract,
218+
stub-`op` fixture, 38 tests. Drops `additional_information`, adds the
219+
finance-heuristic ranker (§9). The §2.1 grep guard landed here rather
220+
than in P4: a boundary is worth more from the moment the code exists.
185221
- **P2 — editor integration.** Endpoint, vault picker, both modes, the
186222
wizard filter box (§9), seal-time honesty gate. Deterministic test via the
187223
existing edit-server harness with the stub `op` on PATH.
188224
- **P3 — review-time re-import.** Same button in review mode, dedup against
189225
the decrypted register, "new since last pull" framing.
190-
- **P4 — docs + guards.** README + site get-started ("have 1Password? two
191-
minutes instead of twenty"), SECURITY.md boundary note (§2), AGENTS.md
192-
line, CI grep test that `--reveal`/concealed-field reads appear nowhere.
226+
- **P4 — docs.** README + site get-started ("have 1Password? two minutes
227+
instead of twenty"), SECURITY.md boundary note (§2), AGENTS.md line, and
228+
the 1Password 8 + CLI-integration prerequisite (§9.7). The CI grep guard
229+
it used to own shipped with P1. Docs must state the boundary in plain
230+
English **without quoting the forbidden flags** — the guard is a literal
231+
grep, and it should stay dumb enough that nothing can talk it round.
193232

194233
Rough size: comparable to the browser-seal build (EF-ISS-7) — a day of
195234
sessions.
@@ -255,3 +294,36 @@ category as a hint; P2's wizard gains the filter box.
255294
- Other password managers (Bitwarden etc.) — the candidate JSON contract is
256295
designed for them, but none are built now.
257296
- Windows-native `op` flows (owner tooling is macOS/Linux/WSL, as today).
297+
298+
## 11. P1 build findings (2026-07-29)
299+
300+
Built `scripts/import-1password.sh` + `scripts/map-1password.py`, a stub
301+
`op` (`tests/stub-op/op`) with fixtures, and 38 tests. Suite: 146 pass, 0
302+
fail. Verified twice against the real 563-item vault, not only fixtures —
303+
which is where three defects surfaced that fixtures never would have:
304+
305+
1. **Substring keyword matching is too loose.** "Readwise" matched the
306+
finance hint "wise" and ranked as money. Fixed: hints match from a word
307+
boundary (so "hargreaveslansdown.com" still hits), and the short
308+
ambiguous ones (isa, ira, irs, tax, wise, visa, loan) must match whole
309+
words. Real-vault rank-1 count went 48 → 40.
310+
2. **Non-accounts could outrank accounts.** A secure note called "Trader-7
311+
Coinbase API" scored a finance hit and sorted above real accounts while
312+
being hidden by default. Fixed: accounts occupy ranks 0-3, non-accounts
313+
4-5 — so the "show everything" toggle appends to the list instead of
314+
reshuffling it.
315+
3. **Title clashes are common, not rare: 178 of 563 items share a title**
316+
with another item (the same messy vault behind T-284). They would share
317+
a pointer, so re-import would hide the twin. Mapper now emits
318+
`title_collisions`; P2's wizard must act on it (§5).
319+
320+
Also confirmed live: the not-signed-in and approval-timeout paths are real
321+
and exit cleanly (the vault session expired mid-session and the wrapper
322+
said so in plain English, printing nothing on stdout). Real-vault shape:
323+
563 items → 540 shown by default, 23 hidden, 7 at rank 0, 40 at rank 1,
324+
493 generic. That 493 is the tail the wizard's filter box (§9.6) exists
325+
for, and the strongest argument for P2 taking ergonomics seriously.
326+
327+
Deviations from the spec as written, both deliberate and both above:
328+
`access_pointer` carries the pointer instead of `identifier` (§4), and the
329+
grep guard shipped in P1 instead of P4 (§7).

scripts/import-1password.sh

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env bash
2+
# import-1password.sh — seed the register from your 1Password vault:
3+
# titles, categories and web addresses only, never passwords (EF-ISS-8).
4+
#
5+
# Usage:
6+
# scripts/import-1password.sh [OPTIONS] > candidates.json
7+
#
8+
# Options:
9+
# --vaults-only list your vaults and stop (for the editor's picker)
10+
# --vault NAME only include items from this vault (repeatable)
11+
# --existing FILE JSON {"assets":[{id, provider, access_pointer}]} of the
12+
# register being edited, so items already listed are
13+
# dropped from the result
14+
# --today DATE override today's date (YYYY-MM-DD; tests)
15+
#
16+
# WHAT THIS READS: `op item list` returns item titles, categories, web
17+
# addresses, vault names and timestamps — it does NOT return passwords,
18+
# one-time codes, or any other concealed field, and this script never asks
19+
# for one. It makes exactly two read-only calls (the vault list and the item
20+
# list) and never fetches an individual item or asks 1Password to unmask
21+
# anything; a CI test greps the whole repo to keep it that way. The
22+
# 1Password CSV/1PUX export is deliberately NOT used: exporting writes your
23+
# secrets to disk, which is exactly what this project exists to avoid.
24+
#
25+
# Nothing is written to disk: the pull is piped straight into the mapper and
26+
# the candidate list goes to stdout. The browser editor holds it in the tab
27+
# until you accept entries into the register.
28+
#
29+
# Needs: the 1Password CLI (`op`) signed in — 1Password 8+ desktop app with
30+
# "Integrate with 1Password CLI" turned on, and a 1password.com account
31+
# (standalone vaults are not reachable by the CLI). This is owner-side
32+
# tooling; the executor recovery path gains no dependency from it.
33+
#
34+
# Exit codes: 0 = candidates written, 2 = usage, 3 = op not installed,
35+
# 4 = op installed but no account / not signed in, 5 = the pull failed
36+
# (approval denied, app locked, …), 6 = python3 missing.
37+
set -u
38+
set -o pipefail
39+
40+
HERE="$(cd "$(dirname "$0")" && pwd)"
41+
MAPPER="$HERE/map-1password.py"
42+
43+
VAULTS_ONLY=0
44+
MAP_ARGS=()
45+
while [ $# -gt 0 ]; do
46+
case "$1" in
47+
-h|--help) sed -n '2,34p' "$0"; exit 0 ;;
48+
--vaults-only) VAULTS_ONLY=1; shift ;;
49+
--vault) [ $# -ge 2 ] || { echo "error: --vault needs a name" >&2; exit 2; }
50+
MAP_ARGS+=(--vault "$2"); shift 2 ;;
51+
--existing) [ $# -ge 2 ] || { echo "error: --existing needs a file" >&2; exit 2; }
52+
MAP_ARGS+=(--existing "$2"); shift 2 ;;
53+
--today) [ $# -ge 2 ] || { echo "error: --today needs a date" >&2; exit 2; }
54+
MAP_ARGS+=(--today "$2"); shift 2 ;;
55+
*) echo "error: unknown option: $1" >&2; echo "try: $0 --help" >&2; exit 2 ;;
56+
esac
57+
done
58+
59+
if ! command -v op >/dev/null 2>&1; then
60+
cat >&2 <<'EOF'
61+
error: the 1Password command-line tool (op) is not installed.
62+
63+
macOS: brew install 1password-cli
64+
other: https://developer.1password.com/docs/cli/get-started/
65+
66+
Then, in the 1Password 8 app: Settings > Developer > "Integrate with
67+
1Password CLI". You can also skip this and type your accounts in by hand —
68+
the importer only ever saves you typing.
69+
EOF
70+
exit 3
71+
fi
72+
73+
if ! command -v python3 >/dev/null 2>&1; then
74+
echo "error: python3 is needed to map the pull (owner-side tooling only)." >&2
75+
exit 6
76+
fi
77+
if [ ! -f "$MAPPER" ]; then
78+
echo "error: mapper not found at $MAPPER" >&2
79+
exit 6
80+
fi
81+
82+
ERR="$(mktemp)"
83+
trap 'rm -f "$ERR"' EXIT
84+
85+
# Vault list first: it is small, it is what the picker needs, and it is the
86+
# call that surfaces "not signed in" before we ask for anything else.
87+
if ! VAULTS_JSON="$(op vault list --format=json 2>"$ERR")"; then
88+
MSG="$(cat "$ERR")"
89+
case "$MSG" in
90+
*"no account"*|*"not signed in"*)
91+
cat >&2 <<'EOF'
92+
error: op is installed but no 1Password account is available to it.
93+
94+
1. Open the 1Password 8 app and unlock it.
95+
2. Settings > Developer > tick "Integrate with 1Password CLI".
96+
3. Sign in to a 1password.com account (standalone vaults cannot be
97+
reached by the CLI).
98+
EOF
99+
echo "op said: $MSG" >&2
100+
exit 4 ;;
101+
*)
102+
echo "error: 1Password refused the request." >&2
103+
echo "op said: $MSG" >&2
104+
echo "If an approval dialog appeared, approve it and run this again." >&2
105+
exit 5 ;;
106+
esac
107+
fi
108+
109+
if [ "$VAULTS_ONLY" -eq 1 ]; then
110+
printf '[]' | python3 "$MAPPER" --vaults-json "$VAULTS_JSON" ${MAP_ARGS[@]+"${MAP_ARGS[@]}"}
111+
exit $?
112+
fi
113+
114+
# The pull itself. Piped straight into the mapper and held in memory: the
115+
# raw list never touches disk, and `additional_information` (username and
116+
# email hints) is dropped there rather than carried into the candidate
117+
# JSON. Held rather than streamed so a failed pull prints nothing at all on
118+
# stdout — a half-written candidate list would look like a short vault.
119+
if ! CANDIDATES="$(op item list --format=json 2>"$ERR" |
120+
python3 "$MAPPER" --vaults-json "$VAULTS_JSON" ${MAP_ARGS[@]+"${MAP_ARGS[@]}"})"; then
121+
MSG="$(cat "$ERR")"
122+
echo "error: could not read your item list." >&2
123+
[ -n "$MSG" ] && echo "op said: $MSG" >&2
124+
echo "If 1Password asked for approval, approve it and run this again." >&2
125+
exit 5
126+
fi
127+
printf '%s\n' "$CANDIDATES"

0 commit comments

Comments
 (0)