awsettings 0.1.0__py3-none-any.whl
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.
- awsettings/__init__.py +15 -0
- awsettings/cli.py +323 -0
- awsettings/core.py +180 -0
- awsettings/hooks.py +117 -0
- awsettings/profile.py +147 -0
- awsettings/store.py +79 -0
- awsettings/trust.py +197 -0
- awsettings-0.1.0.dist-info/METADATA +142 -0
- awsettings-0.1.0.dist-info/RECORD +13 -0
- awsettings-0.1.0.dist-info/WHEEL +5 -0
- awsettings-0.1.0.dist-info/entry_points.txt +2 -0
- awsettings-0.1.0.dist-info/licenses/LICENSE +202 -0
- awsettings-0.1.0.dist-info/top_level.txt +1 -0
awsettings/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""awsettings — your agent's permissions and config, following you to the next machine.
|
|
2
|
+
|
|
3
|
+
An agent harness keeps its permission allowlist, enabled tool servers and hooks in
|
|
4
|
+
a local file. Work from a second machine and none of it is there: you re-approve
|
|
5
|
+
the same action, by hand, once per surface, forever, while the copies drift apart
|
|
6
|
+
and every one of them looks correct.
|
|
7
|
+
|
|
8
|
+
awsettings status
|
|
9
|
+
awsettings pull
|
|
10
|
+
awsettings push
|
|
11
|
+
awsettings hook install
|
|
12
|
+
"""
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
awsettings/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""awsettings — your agent's permissions and config, following you to the next machine.
|
|
2
|
+
|
|
3
|
+
awsettings status what is here, what is remote, what differs
|
|
4
|
+
awsettings pull remote -> local (union; never drops a deny)
|
|
5
|
+
awsettings push local -> remote (credentials stripped)
|
|
6
|
+
awsettings hook install run it automatically from now on
|
|
7
|
+
awsettings hook uninstall
|
|
8
|
+
awsettings --self-test
|
|
9
|
+
|
|
10
|
+
Exit **0** did the thing · **1** a rule refused it · **2** could not judge — the
|
|
11
|
+
profile was unreachable, or a settings file would not parse. Never 0 for "I could
|
|
12
|
+
not look": a sync that silently did nothing and one that had nothing to do are
|
|
13
|
+
the same green run otherwise, and telling them apart is the whole point.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .core import SECRET_KEYS, diff_summary, merge, redact
|
|
26
|
+
from .hooks import install_to, installed, uninstall_from
|
|
27
|
+
from .profile import resolve
|
|
28
|
+
from .store import CouldNotRunError, local_settings_path, read_json, write_json
|
|
29
|
+
from .trust import UntrustedProfileError
|
|
30
|
+
|
|
31
|
+
DEBOUNCE_STAMP = Path.home() / ".awsettings" / "last-push"
|
|
32
|
+
DEBOUNCE_SECONDS = 5.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _root(args) -> Path | None:
|
|
36
|
+
if args.user:
|
|
37
|
+
return None
|
|
38
|
+
return Path(args.root or os.getcwd())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_status(args) -> int:
|
|
42
|
+
target = local_settings_path(_root(args))
|
|
43
|
+
backend = resolve(args.url, args.profile)
|
|
44
|
+
local = read_json(target)
|
|
45
|
+
try:
|
|
46
|
+
remote = backend.get()
|
|
47
|
+
except CouldNotRunError as exc:
|
|
48
|
+
print(f"local: {target}")
|
|
49
|
+
print(f"remote: {backend.describe()}")
|
|
50
|
+
print(f"DEAD: {exc}")
|
|
51
|
+
return 2
|
|
52
|
+
merged = merge(local, remote)
|
|
53
|
+
lines = diff_summary(local, merged)
|
|
54
|
+
print(f"local: {target} ({'present' if target.is_file() else 'absent'})")
|
|
55
|
+
print(f"remote: {backend.describe()}")
|
|
56
|
+
print(f"hooks: {', '.join(installed(local)) or 'not installed'}")
|
|
57
|
+
if not lines:
|
|
58
|
+
print("in step: a pull would change nothing")
|
|
59
|
+
return 0
|
|
60
|
+
print(f"a pull would apply {len(lines)} change(s):")
|
|
61
|
+
for ln in lines:
|
|
62
|
+
print(" " + ln)
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_pull(args) -> int:
|
|
67
|
+
target = local_settings_path(_root(args))
|
|
68
|
+
backend = resolve(args.url, args.profile)
|
|
69
|
+
try:
|
|
70
|
+
local = read_json(target)
|
|
71
|
+
remote = backend.get()
|
|
72
|
+
except CouldNotRunError as exc:
|
|
73
|
+
# Offline is the normal state of a laptop. Say so and keep the local file.
|
|
74
|
+
if not args.quiet:
|
|
75
|
+
print(f"DEAD: {exc}")
|
|
76
|
+
return 2
|
|
77
|
+
merged = merge(local, remote, prune_denies=args.prune_denies)
|
|
78
|
+
lines = diff_summary(local, merged)
|
|
79
|
+
if not lines:
|
|
80
|
+
if not args.quiet:
|
|
81
|
+
print("already in step")
|
|
82
|
+
return 0
|
|
83
|
+
if args.dry_run:
|
|
84
|
+
print(f"would apply {len(lines)} change(s) to {target}:")
|
|
85
|
+
for ln in lines:
|
|
86
|
+
print(" " + ln)
|
|
87
|
+
return 0
|
|
88
|
+
write_json(target, merged)
|
|
89
|
+
if not args.quiet:
|
|
90
|
+
print(f"applied {len(lines)} change(s) to {target}:")
|
|
91
|
+
for ln in lines:
|
|
92
|
+
print(" " + ln)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_push(args) -> int:
|
|
97
|
+
if args.debounce:
|
|
98
|
+
try:
|
|
99
|
+
last = DEBOUNCE_STAMP.stat().st_mtime
|
|
100
|
+
if time.time() - last < DEBOUNCE_SECONDS:
|
|
101
|
+
return 0
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
target = local_settings_path(_root(args))
|
|
105
|
+
backend = resolve(args.url, args.profile)
|
|
106
|
+
try:
|
|
107
|
+
local = read_json(target)
|
|
108
|
+
except CouldNotRunError as exc:
|
|
109
|
+
if not args.quiet:
|
|
110
|
+
print(f"DEAD: {exc}")
|
|
111
|
+
return 2
|
|
112
|
+
snapshot = redact(local)
|
|
113
|
+
if args.dry_run:
|
|
114
|
+
print(json.dumps(snapshot, indent=2))
|
|
115
|
+
return 0
|
|
116
|
+
try:
|
|
117
|
+
backend.put(snapshot)
|
|
118
|
+
except CouldNotRunError as exc:
|
|
119
|
+
if not args.quiet:
|
|
120
|
+
print(f"DEAD: {exc}")
|
|
121
|
+
return 2
|
|
122
|
+
DEBOUNCE_STAMP.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
DEBOUNCE_STAMP.write_text(str(time.time()), encoding="utf-8")
|
|
124
|
+
if not args.quiet:
|
|
125
|
+
kept = sorted(snapshot)
|
|
126
|
+
dropped = sorted(k for k in local if k not in snapshot)
|
|
127
|
+
print(f"pushed {len(kept)} key(s) to {backend.describe()}")
|
|
128
|
+
if kept:
|
|
129
|
+
print(" sent: " + ", ".join(kept))
|
|
130
|
+
if dropped:
|
|
131
|
+
print(" kept local: " + ", ".join(dropped))
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_hook(args) -> int:
|
|
136
|
+
target = local_settings_path(_root(args))
|
|
137
|
+
if args.action == "install":
|
|
138
|
+
path, evs = install_to(target)
|
|
139
|
+
print(f"installed into {path}: {', '.join(evs) or 'nothing'}")
|
|
140
|
+
print(" SessionStart -> pull (a new machine is stale before its first tool call)")
|
|
141
|
+
print(" PostToolUse -> push (debounced; a rule you approve here is on its way)")
|
|
142
|
+
return 0
|
|
143
|
+
path, evs = uninstall_from(target)
|
|
144
|
+
print(f"removed from {path}; still installed: {', '.join(evs) or 'nothing'}")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def self_test() -> int:
|
|
149
|
+
"""Offline proof of the properties that can do harm. No network, no profile."""
|
|
150
|
+
import tempfile
|
|
151
|
+
problems: list[str] = []
|
|
152
|
+
|
|
153
|
+
# --- redaction: by NAME, not by shape --------------------------------
|
|
154
|
+
src = {
|
|
155
|
+
"env": {"OPENAI_API_KEY": "sk-real"},
|
|
156
|
+
"apiKeyHelper": "/bin/print-my-token",
|
|
157
|
+
"sandbox": {"enabled": True, "credentials": {"envVars": [{"name": "X"}]}},
|
|
158
|
+
"permissions": {"allow": ["Bash(git *)"]},
|
|
159
|
+
"someLocalExperiment": 1,
|
|
160
|
+
}
|
|
161
|
+
red = redact(src)
|
|
162
|
+
for k in ("env", "apiKeyHelper"):
|
|
163
|
+
if k in red:
|
|
164
|
+
problems.append(f"redact() let the credential key {k!r} through")
|
|
165
|
+
# Both halves, because the first alone passes VACUOUSLY when `sandbox` is not
|
|
166
|
+
# synced at all — which is exactly the state this shipped in, making the guard
|
|
167
|
+
# unreachable while its check stayed green.
|
|
168
|
+
if "sandbox" not in red:
|
|
169
|
+
problems.append("redact() dropped `sandbox` entirely, so the credential-subkey "
|
|
170
|
+
"guard is unreachable — a protection nothing can run")
|
|
171
|
+
elif "credentials" in red["sandbox"]:
|
|
172
|
+
problems.append("redact() left sandbox.credentials in the snapshot")
|
|
173
|
+
elif red["sandbox"].get("enabled") is not True:
|
|
174
|
+
problems.append("redact() stripped more of `sandbox` than the credentials")
|
|
175
|
+
if "someLocalExperiment" in red:
|
|
176
|
+
problems.append("redact() pushed an unknown local key instead of leaving it home")
|
|
177
|
+
if src["env"]["OPENAI_API_KEY"] != "sk-real":
|
|
178
|
+
problems.append("redact() MUTATED its input")
|
|
179
|
+
|
|
180
|
+
# --- merge: union, not replace ---------------------------------------
|
|
181
|
+
local = {"permissions": {"allow": ["Bash(local *)"], "deny": ["Bash(rm -rf *)"]},
|
|
182
|
+
"enabledMcpjsonServers": ["aitheros"], "env": {"S": "keep"}}
|
|
183
|
+
remote = {"permissions": {"allow": ["Bash(portal *)"]},
|
|
184
|
+
"enabledMcpjsonServers": ["awsh"]}
|
|
185
|
+
m = merge(local, remote)
|
|
186
|
+
if sorted(m["permissions"]["allow"]) != ["Bash(local *)", "Bash(portal *)"]:
|
|
187
|
+
problems.append("merge() did not UNION allow rules — a device silently loses "
|
|
188
|
+
"the rule the other one never had")
|
|
189
|
+
if m["permissions"]["deny"] != ["Bash(rm -rf *)"]:
|
|
190
|
+
problems.append("merge() dropped a local deny the remote did not carry")
|
|
191
|
+
if sorted(m["enabledMcpjsonServers"]) != ["aitheros", "awsh"]:
|
|
192
|
+
problems.append("merge() did not union enabledMcpjsonServers")
|
|
193
|
+
if m.get("env", {}).get("S") != "keep":
|
|
194
|
+
problems.append("merge() lost a device-local secret it must never touch")
|
|
195
|
+
if local["permissions"]["allow"] != ["Bash(local *)"]:
|
|
196
|
+
problems.append("merge() MUTATED its local input")
|
|
197
|
+
|
|
198
|
+
# --- a profile may not push credentials DOWN --------------------------
|
|
199
|
+
if merge({}, {"env": {"X": "y"}}).get("env") is not None:
|
|
200
|
+
problems.append("merge() accepted an env block pushed from the profile — a "
|
|
201
|
+
"settings profile is not a secret channel")
|
|
202
|
+
|
|
203
|
+
# --- deny is one-way unless explicitly pruned -------------------------
|
|
204
|
+
if merge({"permissions": {"deny": ["Bash(curl *)"]}},
|
|
205
|
+
{"permissions": {"deny": []}})["permissions"]["deny"] != ["Bash(curl *)"]:
|
|
206
|
+
problems.append("a deny was dropped by a default sync — the one direction this "
|
|
207
|
+
"must never go")
|
|
208
|
+
if merge({"permissions": {"deny": ["Bash(curl *)"]}}, {"permissions": {"deny": []}},
|
|
209
|
+
prune_denies=True)["permissions"]["deny"] != []:
|
|
210
|
+
problems.append("prune_denies=True did not prune")
|
|
211
|
+
|
|
212
|
+
# --- hooks are idempotent --------------------------------------------
|
|
213
|
+
from .hooks import plan
|
|
214
|
+
once = plan({})
|
|
215
|
+
twice = plan(once)
|
|
216
|
+
n = len(twice["hooks"]["SessionStart"])
|
|
217
|
+
if n != 1:
|
|
218
|
+
problems.append(f"installing twice left {n} SessionStart hooks — a hook that "
|
|
219
|
+
f"accumulates runs N times per event")
|
|
220
|
+
if plan({"hooks": {"SessionStart": [{"matcher": "", "hooks": [
|
|
221
|
+
{"type": "command", "command": "somebody-elses-hook"}]}]}
|
|
222
|
+
})["hooks"]["SessionStart"].__len__() != 2:
|
|
223
|
+
problems.append("install clobbered somebody else's hook instead of adding to it")
|
|
224
|
+
from .hooks import uninstall as hk_uninstall
|
|
225
|
+
if installed(hk_uninstall(twice)):
|
|
226
|
+
problems.append("uninstall left one of our hooks behind")
|
|
227
|
+
|
|
228
|
+
# --- a malformed settings file RAISES, never reads as empty -----------
|
|
229
|
+
with tempfile.TemporaryDirectory() as td:
|
|
230
|
+
bad = Path(td) / "settings.local.json"
|
|
231
|
+
bad.write_text("{ not json", encoding="utf-8")
|
|
232
|
+
try:
|
|
233
|
+
read_json(bad)
|
|
234
|
+
problems.append("a malformed settings file read as EMPTY — that is how a "
|
|
235
|
+
"sync overwrites a half-finished edit")
|
|
236
|
+
except CouldNotRunError:
|
|
237
|
+
pass
|
|
238
|
+
# ...and a write is atomic: no .tmp left behind.
|
|
239
|
+
good = Path(td) / "out.json"
|
|
240
|
+
write_json(good, {"a": 1})
|
|
241
|
+
if list(Path(td).glob("*.tmp")):
|
|
242
|
+
problems.append("write_json left a .tmp file behind")
|
|
243
|
+
if json.loads(good.read_text(encoding="utf-8")) != {"a": 1}:
|
|
244
|
+
problems.append("write_json did not write what it was given")
|
|
245
|
+
|
|
246
|
+
# --- the write target is the personal file ---------------------------
|
|
247
|
+
with tempfile.TemporaryDirectory() as td:
|
|
248
|
+
if local_settings_path(Path(td)).name != "settings.local.json":
|
|
249
|
+
problems.append("the write target is not settings.local.json — syncing into "
|
|
250
|
+
"the committed settings.json hands the whole team one "
|
|
251
|
+
"person's permission rules")
|
|
252
|
+
|
|
253
|
+
# --- the secret list is not empty ------------------------------------
|
|
254
|
+
if not SECRET_KEYS:
|
|
255
|
+
problems.append("the credential denylist is empty, so redact() is a no-op")
|
|
256
|
+
|
|
257
|
+
if problems:
|
|
258
|
+
print("SELF-TEST FAILED:")
|
|
259
|
+
for p in problems:
|
|
260
|
+
print(" x " + p)
|
|
261
|
+
return 1
|
|
262
|
+
print("self-test ok: credentials never leave or arrive, arrays union instead of "
|
|
263
|
+
"replacing, a deny is never dropped by default, hooks install once and do not "
|
|
264
|
+
"clobber, a malformed file refuses rather than reading empty, writes are "
|
|
265
|
+
"atomic, and the target is the personal settings file")
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def main(argv: list[str] | None = None) -> int:
|
|
270
|
+
ap = argparse.ArgumentParser(prog="awsettings",
|
|
271
|
+
description=__doc__.splitlines()[0])
|
|
272
|
+
ap.add_argument("--version", action="version", version=f"awsettings {__version__}")
|
|
273
|
+
ap.add_argument("--self-test", action="store_true")
|
|
274
|
+
ap.add_argument("--root", help="project root (default: cwd)")
|
|
275
|
+
ap.add_argument("--user", action="store_true",
|
|
276
|
+
help="act on the user-level settings instead of a project's")
|
|
277
|
+
ap.add_argument("--url", help="profile endpoint (default: $AWSETTINGS_URL)")
|
|
278
|
+
ap.add_argument("--profile", help="profile FILE (default: $AWSETTINGS_PROFILE)")
|
|
279
|
+
ap.add_argument("--quiet", action="store_true")
|
|
280
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
281
|
+
|
|
282
|
+
sub.add_parser("status")
|
|
283
|
+
p = sub.add_parser("pull")
|
|
284
|
+
p.add_argument("--dry-run", action="store_true")
|
|
285
|
+
p.add_argument("--prune-denies", action="store_true",
|
|
286
|
+
help="allow the profile to REMOVE deny/ask rules (off by default: a "
|
|
287
|
+
"lost deny costs the thing it prevented)")
|
|
288
|
+
p = sub.add_parser("push")
|
|
289
|
+
p.add_argument("--dry-run", action="store_true")
|
|
290
|
+
p.add_argument("--debounce", action="store_true")
|
|
291
|
+
p = sub.add_parser("hook")
|
|
292
|
+
p.add_argument("action", choices=["install", "uninstall"])
|
|
293
|
+
|
|
294
|
+
args = ap.parse_args(argv)
|
|
295
|
+
if args.self_test:
|
|
296
|
+
return self_test()
|
|
297
|
+
for name in ("dry_run", "prune_denies", "debounce"):
|
|
298
|
+
if not hasattr(args, name):
|
|
299
|
+
setattr(args, name, False)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
if args.cmd == "status":
|
|
303
|
+
return cmd_status(args)
|
|
304
|
+
if args.cmd == "pull":
|
|
305
|
+
return cmd_pull(args)
|
|
306
|
+
if args.cmd == "push":
|
|
307
|
+
return cmd_push(args)
|
|
308
|
+
if args.cmd == "hook":
|
|
309
|
+
return cmd_hook(args)
|
|
310
|
+
except UntrustedProfileError as exc:
|
|
311
|
+
# A profile that cannot be proven yours is REFUSED, and the settings
|
|
312
|
+
# are not applied. This is the one failure that must never be quiet.
|
|
313
|
+
print(f"REFUSED: {exc}")
|
|
314
|
+
return 2
|
|
315
|
+
except CouldNotRunError as exc:
|
|
316
|
+
print(f"DEAD: {exc}")
|
|
317
|
+
return 2
|
|
318
|
+
ap.print_help()
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
sys.exit(main())
|
awsettings/core.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The merge and the redaction. Pure, offline, and the only place harm can happen.
|
|
2
|
+
|
|
3
|
+
Everything in this module is a function of its arguments. That is deliberate: the
|
|
4
|
+
two operations that can hurt you — sending a credential somewhere, and losing a
|
|
5
|
+
rule you were relying on — need no network to test, so they are tested without
|
|
6
|
+
one.
|
|
7
|
+
|
|
8
|
+
THE THREE RULES, each already paid for:
|
|
9
|
+
|
|
10
|
+
1. **Credentials never travel, and are dropped by NAME.** Not by inspecting the
|
|
11
|
+
value. A "does this look like a token" heuristic passes every secret that does
|
|
12
|
+
not look like one, and that failure is invisible until the secret is already
|
|
13
|
+
published. A name-based denylist is auditable; a shape-based one is a hope.
|
|
14
|
+
|
|
15
|
+
2. **Arrays UNION; they are never replaced.** A settings file is edited from more
|
|
16
|
+
than one place, so replace semantics means device B silently loses the rule
|
|
17
|
+
device A never had. This is not hypothetical: it is what happens to a shared
|
|
18
|
+
config any time one writer's copy is stamped over another's.
|
|
19
|
+
|
|
20
|
+
3. **A deny is ONE-WAY.** A sync may ADD a deny or ask rule; it may never drop
|
|
21
|
+
one. A lost allow rule costs a prompt. A lost deny rule costs the thing the
|
|
22
|
+
deny existed to prevent — silently, on a machine whose owner still believes it
|
|
23
|
+
is there. `prune_denies` exists for the deliberate case and is never a default.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import copy
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
#: Array-valued keys that MERGE as a union rather than replacing (rule 2).
|
|
31
|
+
UNION_ARRAYS: tuple[tuple[str, ...], ...] = (
|
|
32
|
+
("permissions", "allow"),
|
|
33
|
+
("permissions", "deny"),
|
|
34
|
+
("permissions", "ask"),
|
|
35
|
+
("permissions", "additionalDirectories"),
|
|
36
|
+
("enabledMcpjsonServers",),
|
|
37
|
+
("disabledMcpjsonServers",),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: Keys whose VALUES are credentials, or commands that fetch one. Dropped whole
|
|
41
|
+
#: from anything that leaves this machine, and refused on anything arriving.
|
|
42
|
+
SECRET_KEYS = frozenset({
|
|
43
|
+
"env", # arbitrary values, routinely tokens
|
|
44
|
+
"apiKeyHelper",
|
|
45
|
+
"proxyAuthHelper",
|
|
46
|
+
"awsCredentialExport",
|
|
47
|
+
"awsAuthRefresh",
|
|
48
|
+
"gcpAuthRefresh",
|
|
49
|
+
"otelHeadersHelper",
|
|
50
|
+
"policyHelper",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
#: Sub-objects that hold credential material inside an otherwise-syncable key.
|
|
54
|
+
SECRET_SUBKEYS: dict[str, frozenset[str]] = {
|
|
55
|
+
"sandbox": frozenset({"credentials"}),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#: What is worth syncing. An unknown key is left at home rather than guessed at:
|
|
59
|
+
#: it is far more likely a local experiment than a preference somebody wants
|
|
60
|
+
#: pushed to every machine they own.
|
|
61
|
+
SYNCED_KEYS = frozenset({
|
|
62
|
+
"permissions",
|
|
63
|
+
# `sandbox` carries the network allowlist and filesystem rules, which are
|
|
64
|
+
# exactly the kind of thing you want on every machine — and `sandbox.credentials`,
|
|
65
|
+
# which is exactly what must not travel. That pairing is why SECRET_SUBKEYS
|
|
66
|
+
# exists at all; leaving `sandbox` out of this set made that guard UNREACHABLE,
|
|
67
|
+
# i.e. deleted code wearing the shape of a protection. Caught by a test that
|
|
68
|
+
# asserted the strip, not by reading it.
|
|
69
|
+
"sandbox",
|
|
70
|
+
"enabledMcpjsonServers",
|
|
71
|
+
"disabledMcpjsonServers",
|
|
72
|
+
"enableAllProjectMcpServers",
|
|
73
|
+
"hooks",
|
|
74
|
+
"outputStyle",
|
|
75
|
+
"statusLine",
|
|
76
|
+
"alwaysThinkingEnabled",
|
|
77
|
+
"autoCompactEnabled",
|
|
78
|
+
"spinnerTipsEnabled",
|
|
79
|
+
"todoFeatureEnabled",
|
|
80
|
+
"attribution",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def redact(settings: dict[str, Any]) -> dict[str, Any]:
|
|
85
|
+
"""The snapshot that may leave this machine. Never mutates the input."""
|
|
86
|
+
out: dict[str, Any] = {}
|
|
87
|
+
for k, v in settings.items():
|
|
88
|
+
if k in SECRET_KEYS or k not in SYNCED_KEYS:
|
|
89
|
+
continue
|
|
90
|
+
if k in SECRET_SUBKEYS and isinstance(v, dict):
|
|
91
|
+
v = {sk: sv for sk, sv in v.items() if sk not in SECRET_SUBKEYS[k]}
|
|
92
|
+
out[k] = copy.deepcopy(v)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get(d: dict[str, Any], path: tuple[str, ...]) -> Any:
|
|
97
|
+
cur: Any = d
|
|
98
|
+
for seg in path:
|
|
99
|
+
if not isinstance(cur, dict) or seg not in cur:
|
|
100
|
+
return None
|
|
101
|
+
cur = cur[seg]
|
|
102
|
+
return cur
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _set(d: dict[str, Any], path: tuple[str, ...], value: Any) -> None:
|
|
106
|
+
cur = d
|
|
107
|
+
for seg in path[:-1]:
|
|
108
|
+
nxt = cur.get(seg)
|
|
109
|
+
if not isinstance(nxt, dict):
|
|
110
|
+
nxt = {}
|
|
111
|
+
cur[seg] = nxt
|
|
112
|
+
cur = nxt
|
|
113
|
+
cur[path[-1]] = value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def merge(local: dict[str, Any], remote: dict[str, Any], *,
|
|
117
|
+
prune_denies: bool = False) -> dict[str, Any]:
|
|
118
|
+
"""Remote over local, UNION on the array keys, credentials untouched.
|
|
119
|
+
|
|
120
|
+
Neither input is mutated. A credential block arriving from the remote is
|
|
121
|
+
REFUSED — a settings profile is not a secret channel, and a client that
|
|
122
|
+
accepts one becomes a way to push an `apiKeyHelper` at somebody.
|
|
123
|
+
"""
|
|
124
|
+
out = copy.deepcopy(local)
|
|
125
|
+
|
|
126
|
+
for k, v in remote.items():
|
|
127
|
+
if k in SECRET_KEYS:
|
|
128
|
+
continue
|
|
129
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
130
|
+
merged = dict(out[k])
|
|
131
|
+
merged.update(v)
|
|
132
|
+
out[k] = merged
|
|
133
|
+
else:
|
|
134
|
+
out[k] = copy.deepcopy(v)
|
|
135
|
+
|
|
136
|
+
for path in UNION_ARRAYS:
|
|
137
|
+
lv, rv = _get(local, path), _get(remote, path)
|
|
138
|
+
if lv is None and rv is None:
|
|
139
|
+
continue
|
|
140
|
+
lv = lv if isinstance(lv, list) else []
|
|
141
|
+
rv = rv if isinstance(rv, list) else []
|
|
142
|
+
if prune_denies and path[-1] in ("deny", "ask"):
|
|
143
|
+
union = list(rv)
|
|
144
|
+
else:
|
|
145
|
+
union = list(lv)
|
|
146
|
+
union += [x for x in rv if x not in union]
|
|
147
|
+
_set(out, path, union)
|
|
148
|
+
|
|
149
|
+
# Device-local secrets survive verbatim. Stated explicitly rather than left
|
|
150
|
+
# to fall out of the loop above, because "preserved by omission" is exactly
|
|
151
|
+
# the kind of property a later refactor removes without noticing.
|
|
152
|
+
for k in SECRET_KEYS:
|
|
153
|
+
if k in local:
|
|
154
|
+
out[k] = copy.deepcopy(local[k])
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def diff_summary(before: dict[str, Any], after: dict[str, Any]) -> list[str]:
|
|
159
|
+
"""Human-readable list of what a merge actually changed.
|
|
160
|
+
|
|
161
|
+
A sync that reports "ok" and cannot say what it did is indistinguishable
|
|
162
|
+
from one that did nothing, which is how drift goes unnoticed for months.
|
|
163
|
+
"""
|
|
164
|
+
lines: list[str] = []
|
|
165
|
+
for path in UNION_ARRAYS:
|
|
166
|
+
b = _get(before, path) or []
|
|
167
|
+
a = _get(after, path) or []
|
|
168
|
+
added = [x for x in a if x not in b]
|
|
169
|
+
removed = [x for x in b if x not in a]
|
|
170
|
+
label = ".".join(path)
|
|
171
|
+
for x in added:
|
|
172
|
+
lines.append(f"+ {label}: {x}")
|
|
173
|
+
for x in removed:
|
|
174
|
+
lines.append(f"- {label}: {x}")
|
|
175
|
+
for k in sorted(set(before) | set(after)):
|
|
176
|
+
if k in SECRET_KEYS or any(k == p[0] for p in UNION_ARRAYS):
|
|
177
|
+
continue
|
|
178
|
+
if before.get(k) != after.get(k):
|
|
179
|
+
lines.append(f"~ {k}")
|
|
180
|
+
return lines
|
awsettings/hooks.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The autonomy half: run the sync when a session opens, not when someone remembers.
|
|
2
|
+
|
|
3
|
+
**Not a daemon, on purpose.** A background process is a second thing to keep
|
|
4
|
+
alive on every surface this exists to stop hand-maintaining — and when it dies
|
|
5
|
+
it dies quietly, which is the same failure as the drift it was meant to fix.
|
|
6
|
+
A hook runs at a moment that already exists, has an owner, and is visible in the
|
|
7
|
+
config file it lives in.
|
|
8
|
+
|
|
9
|
+
Two hooks, and the split matters:
|
|
10
|
+
|
|
11
|
+
* **SessionStart -> pull.** The moment a new machine, shell or container opens a
|
|
12
|
+
session is exactly when its settings are most likely to be stale, and it is
|
|
13
|
+
before any tool call has been refused.
|
|
14
|
+
* **A settings write -> push.** Debounced, fail-soft. The trigger is the file
|
|
15
|
+
actually changing, so a rule you approve on one machine is on its way to the
|
|
16
|
+
others before you have finished the thought.
|
|
17
|
+
|
|
18
|
+
Both are installed INTO the settings file the harness already reads, and both
|
|
19
|
+
are idempotent: installing twice leaves one hook, because a hook that
|
|
20
|
+
accumulates copies is a hook that eventually runs N times per event.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .store import read_json, write_json
|
|
28
|
+
|
|
29
|
+
#: Marker that identifies OUR hook entries. Matching on the command string alone
|
|
30
|
+
#: would fail the moment the command is reworded; a marker key survives that and
|
|
31
|
+
#: makes removal exact rather than best-effort.
|
|
32
|
+
MARKER = "awsettings"
|
|
33
|
+
|
|
34
|
+
PULL_COMMAND = "awsettings pull --quiet || true"
|
|
35
|
+
PUSH_COMMAND = "awsettings push --quiet --debounce || true"
|
|
36
|
+
|
|
37
|
+
#: `|| true` on both is deliberate. A settings sync must never be able to fail a
|
|
38
|
+
#: session open or a tool call: offline is the normal state of a laptop, and the
|
|
39
|
+
#: correct behaviour offline is to keep the local file and carry on.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _entry(command: str, status_message: str) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"matcher": "",
|
|
45
|
+
"hooks": [{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"command": command,
|
|
48
|
+
"timeout": 20,
|
|
49
|
+
"statusMessage": status_message, # harness schema key: camelCase is theirs
|
|
50
|
+
MARKER: True,
|
|
51
|
+
}],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_ours(group: dict[str, Any]) -> bool:
|
|
56
|
+
return any(h.get(MARKER) for h in group.get("hooks", []) if isinstance(h, dict))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def plan(settings: dict[str, Any]) -> dict[str, Any]:
|
|
60
|
+
"""Return settings with our two hooks installed exactly once. Pure."""
|
|
61
|
+
import copy
|
|
62
|
+
out = copy.deepcopy(settings)
|
|
63
|
+
hooks = out.setdefault("hooks", {})
|
|
64
|
+
if not isinstance(hooks, dict):
|
|
65
|
+
raise TypeError("settings.hooks is not an object")
|
|
66
|
+
|
|
67
|
+
for event, command, msg in (
|
|
68
|
+
("SessionStart", PULL_COMMAND, "Syncing agent settings..."),
|
|
69
|
+
("PostToolUse", PUSH_COMMAND, "Pushing agent settings..."),
|
|
70
|
+
):
|
|
71
|
+
groups = [g for g in hooks.get(event, []) if isinstance(g, dict)]
|
|
72
|
+
# Drop any previous copy of ours before adding, so install is idempotent
|
|
73
|
+
# and a reworded command replaces rather than accumulates.
|
|
74
|
+
groups = [g for g in groups if not _is_ours(g)]
|
|
75
|
+
groups.append(_entry(command, msg))
|
|
76
|
+
hooks[event] = groups
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def uninstall(settings: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
"""Remove only our entries, leaving every other hook untouched."""
|
|
82
|
+
import copy
|
|
83
|
+
out = copy.deepcopy(settings)
|
|
84
|
+
hooks = out.get("hooks")
|
|
85
|
+
if not isinstance(hooks, dict):
|
|
86
|
+
return out
|
|
87
|
+
for event in list(hooks):
|
|
88
|
+
groups = [g for g in hooks.get(event, [])
|
|
89
|
+
if isinstance(g, dict) and not _is_ours(g)]
|
|
90
|
+
if groups:
|
|
91
|
+
hooks[event] = groups
|
|
92
|
+
else:
|
|
93
|
+
del hooks[event]
|
|
94
|
+
if not hooks:
|
|
95
|
+
out.pop("hooks", None)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def installed(settings: dict[str, Any]) -> list[str]:
|
|
100
|
+
"""Which events currently carry our hook."""
|
|
101
|
+
hooks = settings.get("hooks")
|
|
102
|
+
if not isinstance(hooks, dict):
|
|
103
|
+
return []
|
|
104
|
+
return sorted(ev for ev, groups in hooks.items()
|
|
105
|
+
if any(_is_ours(g) for g in groups if isinstance(g, dict)))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def install_to(path: Path) -> tuple[Path, list[str]]:
|
|
109
|
+
data = read_json(path)
|
|
110
|
+
write_json(path, plan(data))
|
|
111
|
+
return path, installed(read_json(path))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def uninstall_from(path: Path) -> tuple[Path, list[str]]:
|
|
115
|
+
data = read_json(path)
|
|
116
|
+
write_json(path, uninstall(data))
|
|
117
|
+
return path, installed(read_json(path))
|