Visitar URL original
Add secrets_sync.py to Sync Secret Files With 1Password Through op by ptr727 · Pull Request #4165 · ptr727/ProjectTemplate · GitHub
Skip to content

Add secrets_sync.py to Sync Secret Files With 1Password Through op - #4165

Merged
ptr727 merged 17 commits into
developfrom
feature/secrets-sync
Nov 14, 2026
Merged

ptr727 merged 17 commits into
developfrom
feature/secrets-sync

Conversation

@ptr727

@ptr727 ptr727 commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds scripts/secrets_sync.py, an attended hub tool. It syncs secret files between a home directory and 1Password through the op CLI. It ships with unit tests in tests/test_secrets_sync.py and a section in scripts/README.md.

  • A bucket is a named set of secret files owned by one project or host. Each bucket is one vault named exactly Secrets <bucket>, where the name matches ^[a-z0-9-]+$.
  • Each file is one Document item, titled with its POSIX path relative to the home directory. Its mode rides as a mode:<octal> tag.
  • No local state and no tracked per-item state. The live comparison is the truth. Consumers keep reading flat files.
  • Subcommands: status, push, pull, archive, selftest. The README section states each one's behavior and every guard.

The tool is stdlib-only. It follows scripts/handoff.py's shape: a Parser whose usage errors exit 1, Refusal (1) and Execution (2), and a catch-all that maps an unexpected error to 2, printing its type only. Every op call goes through one runner, run_op, which caps stderr, and --dry-run sits on that same path. SIGINT and SIGTERM take one path, which stops the child, signs out a session the run created, and removes temporary files. The run then ends by re-raising the signal.

tests/test_cli_usage.py gains op among its recorded stubs, so no entry point's run reaches a real op. It also gains status as the BEFORE_FLAG argument for the new script. cspell.json gains the words the new files use.

selftest on op 2.42.0

The maintainer ran selftest against a real op 2.42.0 on Linux, at head c25adbd, on constructed data only.

Behavior Result
Document round-trip, trailing newline holds
Document round-trip, no trailing newline holds
Document round-trip, CRLF holds
Document round-trip, NUL bytes holds
document create --tags sets the tags holds
item list rows carry updated_at holds
item list rows carry tags holds
item edit --tags replaces the tags holds
document edit keeps the item id holds
updated_at advances on document edit holds
document edit round-trip holds
document edit keeps the tags holds
An archived item leaves the listing holds
document get on an archived item, by id measured: exits 0, so the item stays readable by id
vault delete removes a vault that still holds items measured: holds, with no prompt
Sign-in: whoami refuses, op signin prompts on the terminal, the POSIX export line parses, whoami accepts, op signout at the end holds
No account configured refused as coded, before this round added the bootstrap

On Windows 11 the maintainer confirmed that op signin and op whoami work with sign-in assisted by the 1Password desktop app. That ran op itself, not this tool. The tool's own run on Windows is still unexercised. That is the path where op signin prints no session assignment and op whoami then accepts, which a unit test covers through the fake.

Not exercised: --account with several accounts, the PowerShell export form, and the tool's own run on Windows.

Verification

  • python3 tests/test_secrets_sync.py passes locally on Linux (Python 3.13). The two interrupt cases run the script under its interpreter with an executable stub op on PATH. One signals mid-call and asserts death by the signal, the child stopped, the created session signed out, and no temporary file left.
  • uv run --no-project --with-requirements tests/requirements.txt python tests/test_cli_usage.py and ... tests/test_backlog_metrics.py pass. tests/test_tooling_encoding.py passes.
  • ruff check, ruff format, and mypy are clean over the changed files. The prose gate is clean over the diff.
  • Each guard was checked by deleting it and confirming a test fails. Every deletion was caught, except a few redundant guards a later round removed.
  • No real op ran anywhere. The host has none installed, so everything above is against the fake or the stub. The attended commands below are the live proof, and they need the maintainer.

Attended verification for the maintainer

Run these from a hub checkout. Each uses constructed names, so substitute your own bucket and file.

python3 scripts/secrets_sync.py selftest
python3 scripts/secrets_sync.py push --bucket alpha ~/.secrets/alpha_api_token
python3 scripts/secrets_sync.py status --bucket alpha
mkdir -m 700 /tmp/restore-proof && python3 scripts/secrets_sync.py pull --bucket alpha --home /tmp/restore-proof

Local review passes

Each recorded local-strict-review pass ran at the frontier tier and read the full diff against develop.

  • Pass 1 raised 20 findings. The fixes make the signal handler pass over a second signal and have selftest read field names before any parse. They refuse two named paths that differ only in case and a Windows junction as a link. A pull re-reads each target before writing. Missing guard tests were added. The README and test_cli_usage.py findings were the edits held until another pull request released those files.
  • Pass 2 raised 15 findings. The fixes refuse Windows-forbidden title characters, the CONIN$/CONOUT$ device names, and a path miscased against the directory. They report an unreadable local path by name and accept a sign-in the desktop app holds. They share one comparison between status and pull. README claims were corrected and guard tests added. Two guards the read-back already covers were removed as over-hardening.
  • Pass 3 raised 9 findings after the first push's edit budget was spent. All are fixed in a120d78: a named stale path is refused, a linked title shows as link in status, and the unreachable signout and unread flag are gone. The README's re-check claim and the restore-root docstring were corrected, and the untested guards gained tests.
  • Pass 4 raised 11 findings, fixed in cb2e0cf. selftest warns when the vault it made cannot be listed, and prints its results before removing scratch files. Messages name the operation a missing item followed. Tests now cover the second signal, a junction as the restore root, an absent archive vault, a vault create that lists nothing, a missing op, and the stderr cap.
  • Pass 5 raised 2 findings, fixed in 2c2b547. A pull skips a local file newer than the remote copy unless --overwrite-newer, mirroring push. A dry-run archive and a dry-run update now have tests.
  • Pass 6 raised 3 findings, fixed in d345569. Empty --home, --bucket, and --mode values are usage errors, an unreadable entry under ~/.secrets leaves the unheld list rather than ending status, and archive's prompt is tested.
  • Pass 7 raised 4 findings, fixed in 488b837. An empty --account is a usage error, a path under an unlistable directory is refused by name, every op call prints a progress line, and pull, archive, and selftest each prove they need a terminal.
  • Pass 8 raised 3 findings, fixed in 3e2da95. A mode-only push is judged for staleness, a push refuses an existing item's malformed tag unless --mode names the replacement, and a test proves the temporary file is 0600 before the secret lands in it.
  • Pass 9 raised 2 findings, fixed in a0c2dd8. --mode applies only to files named on the command line, and each plan line shows a mode the write changes as was->now.
  • Pass 10 raised 2 findings. The listed() consolidation is fixed in df78664. The staleness finding went to the maintainer, whose decision is below.
  • Pass 11 raised 3 findings. Two are fixed in c25adbd: digest prefixes come from an HMAC keyed per run, so an eight-character prefix reveals nothing about a short secret, and a reserved name with a space before its dot or a superscript digit is refused. The third went to the maintainer, whose decision is below.
  • Pass 12 raised 2 findings, fixed in d45ddf7.
  • Pass 13 raised 3 findings. Two are fixed in 8fd4359: the docstring no longer credits selftest with measuring sign-in, and a new file's group is stated as the platform's choice. The third waits on the design seat, below.
  • Pass 14 raised the 2 findings below after this push's edit budget was spent.

Maintainer decisions, applied in d45ddf7

  • Staleness judges content only. A file whose content matches and whose mode differs is a tag update, planned and confirmed like any other change, with no time check. ctime is deliberately not used, since a rename or chmod moves it and would hide a stale copy.
  • pull keeps the temporary file and os.replace. A replaced file's group is set on the temporary file before the rename, and a group it cannot keep is a refusal. A new file takes the account's default group. The README states that ACLs and extended attributes are not carried.
  • Account bootstrap: with no account configured and a terminal on stdin, the tool runs op account add on the terminal, then re-reads the account list and continues into the session. Without a terminal it refuses as before. The tool prints no account address itself.

The pass-12 findings are fixed in d45ddf7: the case-collision test reads the right refusal on a case-insensitive file system, and the README's link rule says "below the home directory". The README now names host-setup/linux/install-tools.sh --install op as where op comes from, and the docstrings say which behaviors 2.42.0 measured.

Open, waiting on the design seat

  • scripts/secrets_sync.py cmd_pull: a retag moves the remote updated_at forward without changing content. So a host holding a newer, unpushed copy of that file reads it as older on its next pull, and --yes overwrites it. A later push from that host also skips the file as stale. The recommendation is to record each pushed file's content time as a second tag, mtime:<iso>, and judge staleness against that rather than updated_at. Class: introduced.

Open findings from pass 14 (introduced)

  • scripts/secrets_sync.py push_plans: a push lists only its own bucket, so pushing a title another bucket holds creates a second owner. The duplicate is refused only when both buckets are pulled together. Class: introduced.
  • scripts/secrets_sync.py write_secret: os.replace makes the running user the owner of a replaced file that another uid owned. The README names the group as carried, and ACLs and extended attributes as not carried, but it does not mention the owner. Class: introduced.

Declined

  • Consolidating Parser/Refusal/Execution/report_stderr/rows/run/reraise with scripts/handoff.py and catalog/snippets/hub_fetch_run.py. The snippet is carried standalone and cannot import a sibling. handoff.py's helpers are bound to gh. A shared module would edit another tool and its tests, which is a separate change. Class: design.

Notes

  • The pull request runs no real op. The host has none, and every behavior above is against the in-memory fake or the executable stub.
  • pull sets the mode on the open temporary file's descriptor before os.replace, rather than on the path afterwards. The file then never appears under another mode, and no chmod follows a path.

🤖 Generated with Claude Code

ptr727 and others added 5 commits November 13, 2026 09:09
An attended hub tool that keeps a home directory's secret files in a
1Password vault per bucket, one Document item per file, with the mode
carried as a tag. The live comparison is the only state, so nothing
local can drift from either side.

The unit tests drive every guard through a fake op, and two interrupt
cases run the script with an executable stub op on PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signs out a session whose signin output fails to parse, passes over a
second signal so cleanup runs to the end, and refuses a Windows junction
as it does a symbolic link. A pull re-reads each target before writing,
two named paths differing only in case are refused, and selftest reads
the listing's field names before any parse can stop it. The tests now
fail each guard the pass found untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds its section to scripts/README.md and names it in the utility,
writer, and test lists. tests/test_cli_usage.py stubs op, so no run of
any entry point reaches a real op, and declares status as the argument
that satisfies the parser before the unknown flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refuses every character and device name Windows forbids in a title, and
a path spelled in another case than the directory spells it. Reports an
unreadable local path by name, and accepts a sign-in the desktop app
holds without signing it out. Deletes the selftest vault before its
scratch directory, and shares one comparison between status and pull.
Drops two guards the read-back already covers, corrects the README's
removal and exit-code claims, and tests each guard the pass found
untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings November 13, 2026 18:09
@ptr727

ptr727 commented Nov 13, 2026

Copy link
Copy Markdown
Owner Author

A recorded local strict-review pass covers head f09a18ba232fa9d203f8cb356995bdd0b119ba82, the content this pull request carries at that commit against develop, and it recorded 9 findings.

@coderabbitai

coderabbitai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Refuses a named path whose remote copy is newer instead of exiting 0 on
a skip, and shows a title reached through a link as such in status.
Drops a signout that could carry no session and a flag nothing read.
Scopes the README's session re-check to the subcommands that make it,
names the Windows exception in the restore-root docstring, and tests
the mode bound, the non-UTF-8 exemption, and both failure warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Approval is blocked by active correctness issues, including malformed-session cleanup, linked-parent status reporting, stale named-path handling, and a platform-specific test failure, along with documentation and coverage gaps.

12 open issues
What changed in this PR

Adds an attended, standard-library-only utility for synchronizing secret files with 1Password through the op CLI.

Changes:

  • Adds scripts/secrets_sync.py with status, push, pull, archive, and selftest commands.
  • Adds fake-op unit tests, platform coverage, and interrupt-handling tests.
  • Documents usage and safeguards, extends CLI usage checks, and updates spelling terms.
File Description
tests/​test_secrets_sync.py Adds comprehensive tests for synchronization commands, error handling, cleanup, platform behavior, and interrupt handling.
tests/​test_cli_usage.py Registers the new script and records op invocations so CLI usage tests cannot call a real executable.
scripts/​secrets_sync.py Implements guarded secret-file synchronization with 1Password, session management, safe file operations, verification, and self-testing.
scripts/​README.md Documents the tool's commands, storage model, operational safeguards, and supported op behavior.
cspell.json Adds vocabulary used by the synchronization script, tests, and documentation.

Review effort: Lite

Comment thread tests/test_secrets_sync.py Resolved
Comment thread scripts/secrets_sync.py Resolved
Comment thread scripts/secrets_sync.py Resolved
Comment thread scripts/secrets_sync.py Resolved
Comment thread scripts/README.md Resolved
Comment thread scripts/secrets_sync.py Resolved
Comment thread scripts/secrets_sync.py Resolved
Comment thread scripts/secrets_sync.py Resolved
Comment thread scripts/secrets_sync.py Resolved
Comment thread scripts/secrets_sync.py Resolved
@codecov

codecov Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.41148% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.86%. Comparing base (b38e084) to head (8fd4359).
⚠️ Report is 210 commits behind head on develop.

Files with missing lines Patch % Lines
scripts/secrets_sync.py 96.41% 30 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #4165      +/-   ##
===========================================
- Coverage    81.60%   77.86%   -3.75%     
===========================================
  Files           26       42      +16     
  Lines         9070    15190    +6120     
===========================================
+ Hits          7402    11827    +4425     
- Misses        1668     3363    +1695     
Flag Coverage Δ
python-3.13 77.86% <96.41%> (-3.75%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

ptr727 and others added 2 commits November 13, 2026 10:20
Warns that the selftest vault may need deleting by hand when the listing
after its create fails, and prints the selftest results before removing
the scratch directory. Names the operation a missing item followed, and
says a read-back mismatch is against what the plan read. Corrects the
README's restore-root and selftest-order claims, and tests the second
signal, a junction as the restore root, an absent archive vault, a vault
create that lists nothing, a missing op, and the stderr cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pull mirrors the push's stale guard, so a local file changed after the
remote copy, such as a rotated secret not yet pushed, is skipped rather
than replaced under --yes. Tests a dry-run archive and a dry-run update
of an existing item, so neither can send a write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ptr727

ptr727 commented Nov 13, 2026

Copy link
Copy Markdown
Owner Author

A recorded local strict-review pass covers head 2c2b5477de4d2a2e6061a5063f1ab207ef45bfd6, the content this pull request carries at that commit against develop, and it recorded 3 findings.

ptr727 and others added 2 commits November 13, 2026 10:44
An empty --home, --bucket, or --mode is a usage error rather than an
absent option, so an unset variable can never restore into the real
home. An unreadable entry under ~/.secrets leaves the unheld list rather
than ending status, and a link check a parent's permissions block is
refused by name. Tests prove archive asks before it writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An empty --account is a usage error like the other empty values, and a
path under a directory that cannot be listed is refused naming it. Each
probe and the signout print a progress line as every other op call
does. Tests prove pull, archive, and selftest each need a terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ptr727

ptr727 commented Nov 13, 2026

Copy link
Copy Markdown
Owner Author

A recorded local strict-review pass covers head 488b83787df9c1ac667a3faf3368596490568685, the content this pull request carries at that commit against develop, and it recorded 3 findings.

ptr727 and others added 2 commits November 13, 2026 11:07
A mode change against a newer remote tag needs --overwrite-newer as a
content change does, so it cannot revert another host's newer mode. A
push refuses an existing item's missing or malformed tag unless --mode
names the replacement. A test proves the temporary file is 0600 before
the secret is written into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--mode with no paths would tag every member the refresh touches, so it
needs the files named, and the malformed-tag refusal says to push that
file by name. Each plan line shows a mode the write changes as was->now,
so a loosening is visible before the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ptr727

ptr727 commented Nov 13, 2026

Copy link
Copy Markdown
Owner Author

A recorded local strict-review pass covers head a0c2dd82bd87f9d95a0440bcefaa02f54e3d3e0e, the content this pull request carries at that commit against develop, and it recorded 2 findings.

ptr727 and others added 2 commits November 13, 2026 11:28
The survivor check reads /proc, so a host without one, such as macOS,
skips the interrupt cases rather than failing them. verify takes its
not-listed lookup from listed() rather than repeating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unkeyed eight-character sha256 prefix lets a short secret such as a
PIN be recovered offline, so the prefix comes from an HMAC whose key is
drawn fresh each run. Two copies still compare within one run. A
reserved name with a space before its dot, and COM or LPT with a
superscript digit, are refused as Windows reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ptr727

ptr727 commented Nov 13, 2026

Copy link
Copy Markdown
Owner Author

A recorded local strict-review pass covers head c25adbd9a9a0b8d8489ae04032f11cc5f5d154f1, the content this pull request carries at that commit against develop, and it recorded 2 findings.

@ptr727

ptr727 commented Nov 13, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@ptr727

ptr727 commented Nov 13, 2026

Copy link
Copy Markdown
Owner Author

/review

@coderabbitai

coderabbitai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

ptr727 added a commit that referenced this pull request Nov 13, 2026
## Summary

A Copilot review whose whole body is a GitHub job-start notice naming a
billing failure read as a review round. It covered the head
(`review_on_head=yes`, `coverage=none`), counted toward the round cap,
held the head as `shapes=HELD`, and on an earlier head left a
`shapes=NOTED` part that `merge` refused on until acknowledged.

It now reads as a refusal: it covers nothing, spends no round, and owes
no acknowledgment. It reads as a refusal of no named kind,
`refusal=YES`, and `wait` exits `41` as `REVIEW_IS_A_REFUSAL`, which
goes to the maintainer. It is deliberately not the `quota` kind. Every
status line and remedy that kind drives describes a quota that resets on
its own, and a failed payment or a spending limit never does.

- `scripts/reviewers/copilot.json`: add the refusal opening `job was not
started`. No new refusal kind and no new remedy line.
- `tests/test_pr_review_copilot.py`: the adapter reads a constructed
notice as a refusal with no notes.
- `tests/test_pr_review.py`: the gate reads it as `refusal=YES`, not a
round on the head, no `shapes=HELD`, no round toward the cap, `wait`
exiting `41` as `REVIEW_IS_A_REFUSAL`, and no `shapes=NOTED` on an
earlier head.

Both new tests fail on `develop` without the vocabulary change. The
fixtures are constructed text, not a copied notice.

## Local review findings, declined

- `scripts/pr_review.py:1902-1906` and `scripts/README.md:237`,
`introduced`. The `REVIEW_IS_A_REFUSAL` line says no further review
follows the refusal and that re-requesting the head clears nothing. Once
the maintainer fixes the billing, a re-request does clear it, and only
`wait --ignore-quota-signal` sends one. Declined. The maintainer's
[direction on
#1071](#1071 (comment))
calls a distinct remedy line naming the billing settings optional on top
of reading the notice as a refusal. The line also stays true for as long
as the billing failure stands, and the refusal already goes to the
maintainer, who alone clears it. A billing-specific remedy line would
need `scripts/README.md`, which #4165 holds, and room under
`scripts/pr_review.py`'s 2200-line ceiling, which it sits 2 lines under.

## Test plan

- `python -m unittest test_pr_review_copilot test_pr_review` from
`tests/`, with `tests/requirements.txt`: 656 tests pass.
- Pre-commit hook: ruff format, ruff check, mypy, and the prose gate
pass.

Closes on promotion: #1071

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ptr727 and others added 2 commits November 13, 2026 14:20
… Only

With no account configured and a terminal on stdin, the tool runs
op account add on the terminal, then continues into the session. A pull
sets a replaced file's group on the temporary file before the rename,
and refuses where it cannot keep it. Staleness judges content only, so
a mode-only change is a tag update planned and confirmed like any other.
The docs record what selftest measured on op 2.42.0, say where op comes
from, and qualify the link rule as below the home directory. The
case-collision test reads the right refusal on a case-insensitive file
system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orm's

selftest never runs a sign-in, so the docstring no longer credits it
with measuring one. A new file's group is the platform's choice for
that directory, which macOS and a setgid directory set from the parent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ptr727

ptr727 commented Nov 13, 2026

Copy link
Copy Markdown
Owner Author

A recorded local strict-review pass covers head 8fd43594f8a64a29587b8a5c043bfde80d1991ef, the content this pull request carries at that commit against develop, and it recorded 2 findings.

@ptr727

ptr727 commented Nov 14, 2026

Copy link
Copy Markdown
Owner Author

Outcome of the Three Open Findings

The maintainer decided to defer each of the three findings the body lists as open, and to merge at head 8fd43594. Each is an issue now:

Finding Issue
A retag moves updated_at, so a newer unpushed local copy on a second host reads as older #4190
A push of a title another bucket already holds creates a second owner #4191
A pull makes the running user the owner of a replaced file another uid owned, and the README does not say so #4192

None is reachable on a first push from a single host. All three are to be fixed before the promotion that carries this tool to main, and before a second host uses it.

The tool's own run on Windows is tracked in #4188.

@ptr727
ptr727 merged commit 28af716 into develop Nov 14, 2026
13 checks passed
@ptr727
ptr727 deleted the feature/secrets-sync branch November 14, 2026 00:40
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.

2 participants