@junghanacs/entwurf 0.12.9 → 0.12.10

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.
Files changed (69) hide show
  1. package/AGENTS.md +134 -248
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +19 -0
  4. package/DELIVERY.md +4 -3
  5. package/README.md +26 -13
  6. package/VERIFY.md +3 -2
  7. package/demo/demo.sh +7 -1
  8. package/docs/setup-clean-host.md +10 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +5 -3
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
  23. package/mcp/entwurf-bridge/src/index.ts +190 -82
  24. package/package.json +5 -3
  25. package/pi-extensions/entwurf-control.ts +69 -27
  26. package/pi-extensions/lib/acp/overlay.ts +5 -3
  27. package/pi-extensions/lib/entwurf-core.ts +26 -9
  28. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  29. package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
  30. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  31. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  32. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  33. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  34. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  35. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  36. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  37. package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
  38. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
  39. package/pi-extensions/lib/meta-session.ts +17 -20
  40. package/run.sh +48 -41
  41. package/scripts/agy-bridge-config.py +249 -49
  42. package/scripts/agy-bridge.sh +59 -14
  43. package/scripts/check-acp-carrier-augment.ts +34 -2
  44. package/scripts/check-acp-sdk-surface.ts +22 -11
  45. package/scripts/check-agy-permission-matrix.py +655 -0
  46. package/scripts/check-entwurf-deliverability.ts +9 -6
  47. package/scripts/check-entwurf-fact-provider.ts +7 -7
  48. package/scripts/check-entwurf-facts.ts +12 -12
  49. package/scripts/check-entwurf-resume-args.ts +22 -60
  50. package/scripts/check-entwurf-self-address.ts +187 -4
  51. package/scripts/check-entwurf-v2-contract.ts +2 -2
  52. package/scripts/check-entwurf-v2-production.ts +5 -5
  53. package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
  54. package/scripts/check-entwurf-v2-surface.ts +327 -14
  55. package/scripts/check-gate-qualification.ts +785 -0
  56. package/scripts/check-meta-identity-consumers.ts +1 -1
  57. package/scripts/check-shell-quote.ts +2 -1
  58. package/scripts/lib/mutation-qualify.ts +794 -0
  59. package/scripts/mutants/acp-augment.json +30 -0
  60. package/scripts/mutants/agy-permission.json +144 -0
  61. package/scripts/mutants/meta-identity.json +17 -0
  62. package/scripts/mutants/self-address.json +59 -0
  63. package/scripts/mutants/v2-surface.json +87 -0
  64. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  65. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  66. package/scripts/smoke-agy-install-state.sh +205 -20
  67. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  68. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  69. package/scripts/check-entwurf-mailbox-guard.ts +0 -266
@@ -32,27 +32,42 @@ Subcommands (argv[1]):
32
32
  <target>` prefix / `invalid-json` / `not-configured` / `command <cmd>`. Never mutates.
33
33
 
34
34
  permission-install <settings_path> <state_path>
35
- The OTHER half of "agy can call our bridge": registering the server (above) makes the tool
35
+ The OTHER half of "agy can call our bridge": registering the server (above) makes the tools
36
36
  reachable, but agy's permission engine defaults every `mcp` action to Ask, so every single
37
- entwurf_v2 call stops for a y/n. This adds our allow rule to agy's settings.json.
37
+ call stops for a y/n. This adds our allow rules to agy's settings.json.
38
38
 
39
- Ownership is ELEMENT-level, not subtree: we own exactly the string
40
- `mcp(entwurf-bridge/entwurf_v2)` inside `permissions.allow`. The operator's own rules
41
- (command(*), their ask/deny lists, everything else) are ours to preserve, never to manage
42
- granting ourselves broad permissions would be a trust decision that is not the installer's
43
- to make. Same discipline as the single mcpServers key above. Idempotent; refuses a symlink.
39
+ Ownership is ELEMENT-level, not subtree: we own exactly the strings
40
+ `mcp(entwurf-bridge/<tool>)` for the tools the normal agy workflow calls, inside
41
+ `permissions.allow`. Every tool the server exposes is visible to the model; these are the
42
+ ones we auto-grant, which is a smaller set on purpose.
43
+ The operator's own rules (command(*), their ask/deny lists, everything else) are ours to
44
+ preserve, never to manage — granting ourselves broad permissions would be a trust decision
45
+ that is not the installer's to make. Same discipline as the single mcpServers key above.
46
+ Idempotent; refuses a symlink.
44
47
 
45
48
  permission-uninstall <state_path>
46
- Honest inverse: remove OUR rule only if WE added it (an operator who already had the rule
47
- keeps it), then drop the `allow`/`permissions` containers only if we created them and they
48
- are now empty. Removes the settings file only if we created it and nothing else remains.
49
+ Honest inverse: remove each of OUR rules only if WE added that one (an operator who already
50
+ had a rule keeps it), then drop the `allow`/`permissions` containers only if we created them
51
+ and they are now empty. Removes the settings file only if we created it and nothing remains.
49
52
 
50
53
  permission-doctor <settings_path>
51
54
  One token line: `absent` / `invalid-json` / `not-configured` / `configured` /
52
- `shadowed-by-<list> <rule>`. The shadow check exists because agy evaluates
55
+ `partially-configured <missing...>` / `covered-by-allow <rule>` /
56
+ `shadowed-by-<list> <broad|exact> <rule>`. The shadow check exists because agy evaluates
53
57
  Deny > Ask > Allow: an operator rule like `mcp(*)` in their ask list SILENTLY defeats our
54
58
  allow, and agy starts prompting again with our install still green. That would be a
55
- debugging hole ("why is it asking me every time?"), so the doctor names it instead.
59
+ debugging hole ("why is it asking me every time?"), so the doctor names it instead — with
60
+ the SCOPE, because a broad rule takes every tool while an exact one takes only its own.
61
+
62
+ permission-state-doctor <state_path>
63
+ Is the OWNERSHIP RECORD itself readable? `absent` / `ok <n>` / `corrupt <why>`. Separate from
64
+ permission-doctor, which reads the settings file: runtime can be perfect while the record that
65
+ distinguishes our grants from the operator's is unreadable (hard rule 13, two axes). Uses the
66
+ same strict parser as install/uninstall, so "readable" has one definition. Never mutates.
67
+
68
+ permission-rules
69
+ Print the allow rules we own, space-separated. The single source the shell and gates render
70
+ from, so operator messages can never drift from what install actually writes.
56
71
 
57
72
  Exit codes: 0 ok · 2 no-state · 3 refuse-symlink · 4 invalid-json · 5 usage.
58
73
  """
@@ -62,23 +77,48 @@ import os
62
77
  import sys
63
78
 
64
79
  SERVER_KEY = "entwurf-bridge"
65
- STATE_SCHEMA_VERSION = 1
66
-
67
- # The ONE permission rule we own. Scoped to a single tool on our own servernot mcp(*), not the
68
- # server-wide mcp(entwurf-bridge): an installer grants itself the narrowest rule that makes the
69
- # thing it installed work, and nothing more. INSTALL still writes only this.
70
- ALLOW_RULE = f"mcp({SERVER_KEY}/entwurf_v2)"
71
- # Rules that MATCH our tool: the exact grant, the server-wide rule, and the action wildcard.
80
+ # TWO independent state files, TWO independent schemas. They are versioned apart because they
81
+ # change apart: widening the permission rule set says nothing about the MCP install-state's shape,
82
+ # and stamping a bump onto a file whose layout never moved makes the version a lie the next reader
83
+ # to branch on it would be branching on noise.
84
+ STATE_SCHEMA_VERSION = 1 # MCP install-state (server key + preimage); shape unchanged
85
+ # 2: permission state records a rule SET (`rules` / `rulesExistedBefore`) instead of a single
86
+ # `rule` / `ruleExistedBefore`. Install migrates a v1 state in place; uninstall reads both shapes.
87
+ PERMISSION_STATE_SCHEMA_VERSION = 2
88
+
89
+ # The permission rules we own — one per tool the NORMAL agy workflow calls. (Every tool the server
90
+ # exposes is visible to the model; auto-granting is the smaller, deliberate set.) Each is
91
+ # scoped to a single tool on our own server: not mcp(*), not the server-wide mcp(entwurf-bridge).
92
+ # An installer grants itself the narrowest rules that make the thing it installed work, and nothing
93
+ # more; three narrow grants are still narrow, and none of them is a trust decision about anyone
94
+ # else's server.
95
+ #
96
+ # Granting ONLY entwurf_v2 was a shipped defect (measured 2026-07-27 on a live agy citizen): agy
97
+ # defaults every mcp action to Ask, so `entwurf_peers` and `entwurf_self` stopped for a y/n on
98
+ # EVERY call. The bridge was registered and two thirds of it was unusable — the same failure the
99
+ # doctor already names for entwurf_v2, just never checked for the other two.
100
+ #
101
+ # The two tools deliberately NOT here, because a grant we do not need is a grant we should not take:
102
+ # entwurf_inbox_read — native-push has no inbox to drain. An agy citizen has no meta-mailbox
103
+ # directory at all (measured), so granting it would pre-approve a rail this
104
+ # backend does not have.
105
+ # entwurf_register_native — explicit/manual fallback, not the normal birth path (DELIVERY.md):
106
+ # agy births automatically from PreInvocation. Auto-approving a registration
107
+ # verb the normal path never calls is exactly the excess this list avoids.
108
+ ALLOW_RULES = tuple(f"mcp({SERVER_KEY}/{tool})" for tool in ("entwurf_v2", "entwurf_peers", "entwurf_self"))
109
+ # Rules that MATCH one of our tools: its exact grant, the server-wide rule, and the action wildcard.
72
110
  # Membership here is a statement about agy's matcher, not about which list the rule sits in — the
73
- # same three rules cover entwurf_v2 wherever they appear. From a higher-precedence list they
74
- # override our allow (shadowing); from `allow` itself they already grant it (covering).
111
+ # same rules cover our tools wherever they appear. From a higher-precedence list they override our
112
+ # allow (shadowing); from `allow` itself they already grant it (covering). SCOPE differs, though:
113
+ # the two broad rules cover every tool on the server, an exact rule only its own.
75
114
  #
76
- # The doctor used to read them ONE WAY ONLY — shadowing — and demanded a literal ALLOW_RULE string
115
+ # The doctor used to read them ONE WAY ONLY — shadowing — and demanded a literal own-rule string
77
116
  # in `allow`. So a host whose operator had granted a broad `mcp(*)` (their trust decision, not
78
117
  # ours) was reported as "NOT granted, agy prompts on EVERY entwurf_v2 call": a false red about a
79
118
  # surface that in fact works. Reading the same coverage in both directions is the fix; treating
80
119
  # mcp(*) as matching in deny/ask but not in allow was never defensible.
81
- MATCHING_RULES = (ALLOW_RULE, f"mcp({SERVER_KEY})", "mcp(*)")
120
+ BROAD_RULES = (f"mcp({SERVER_KEY})", "mcp(*)") # cover EVERY tool on our server, not just one
121
+ MATCHING_RULES = ALLOW_RULES + BROAD_RULES
82
122
  SHADOWING_RULES = MATCHING_RULES # same set, higher-precedence lists
83
123
  # Deny > Ask > Allow (agy permissions engine).
84
124
  SHADOWING_LISTS = ("deny", "ask")
@@ -135,6 +175,68 @@ def _prior_state(state_path: str, managed_key: str, managed_path: str) -> dict:
135
175
  return prior
136
176
 
137
177
 
178
+ class _PermissionStateError(Exception):
179
+ """A permission install-state we refuse to act on. Raised, not exited, so the doctor can REPORT
180
+ corruption while install/uninstall REFUSE on it — one parser, three honest reactions."""
181
+
182
+
183
+ def _parse_permission_state(state: dict) -> tuple:
184
+ """Validate a permission install-state; return (rules, {rule: was-it-already-theirs}).
185
+
186
+ Two shapes, because install/uninstall/doctor can all meet a state file an older entwurf wrote:
187
+ schemaVersion 2 carries `rules` + `rulesExistedBefore`; schemaVersion 1 carried a single `rule`
188
+ + `ruleExistedBefore`. Either way the answer is the same question asked per rule, and a rule the
189
+ operator already had is theirs in both.
190
+
191
+ STRICT, because the failure mode deletes someone else's configuration. A missing or non-boolean
192
+ provenance entry read as "not theirs" silently promotes the operator's own rule to ours and the
193
+ inverse takes it away — `rulesExistedBefore: {}` alone would revoke every rule in the file. An
194
+ unknown version, a wrong type, or an INCOMPLETE provenance map is an error, never a permissive
195
+ default. Refusing is recoverable; deleting a grant we never owned is not.
196
+
197
+ ONE parser for every caller. Install used to do its own shallow shape check (dict? values bool?)
198
+ which an EMPTY map passed — `all()` of nothing is true — so a corrupt prior state was silently
199
+ overwritten with freshly re-captured provenance, the exact re-capture the state file exists to
200
+ prevent. A second, laxer copy of a strict rule is the same as not having the rule.
201
+ """
202
+ version = state.get("schemaVersion")
203
+ if version == PERMISSION_STATE_SCHEMA_VERSION:
204
+ rules = state.get("rules")
205
+ existed = state.get("rulesExistedBefore")
206
+ if not isinstance(rules, list) or not rules or not all(isinstance(r, str) for r in rules):
207
+ raise _PermissionStateError(
208
+ "`rules` must be a non-empty list of strings — refusing to guess which rules are ours")
209
+ if not isinstance(existed, dict):
210
+ raise _PermissionStateError(
211
+ "`rulesExistedBefore` must be an object — refusing to act on unreadable provenance")
212
+ missing = [r for r in rules if not isinstance(existed.get(r), bool)]
213
+ if missing:
214
+ raise _PermissionStateError(
215
+ f"no boolean provenance for {' '.join(missing)} — refusing to treat a rule that may "
216
+ "be the operator's as ours")
217
+ return tuple(rules), {r: existed[r] for r in rules}
218
+ if version == 1:
219
+ legacy = state.get("rule")
220
+ owned = state.get("ruleExistedBefore")
221
+ if not isinstance(legacy, str) or not isinstance(owned, bool):
222
+ raise _PermissionStateError(
223
+ "schemaVersion 1 needs a string `rule` and a boolean `ruleExistedBefore`")
224
+ return (legacy,), {legacy: owned}
225
+ raise _PermissionStateError(
226
+ f"unknown schemaVersion {version!r} — this entwurf does not know which rules that install "
227
+ "owned and will not touch any")
228
+
229
+
230
+ def _owned_rules(state: dict) -> list:
231
+ """The rules in this install-state that are OURS to remove — never the operator's."""
232
+ try:
233
+ rules, existed = _parse_permission_state(state)
234
+ except _PermissionStateError as exc:
235
+ _die(4, f"agy-bridge: permission install-state is unusable — {exc}. Check permissions.allow "
236
+ "yourself, then delete the state file by hand if it is genuinely stale.")
237
+ return [r for r in rules if not existed[r]]
238
+
239
+
138
240
  def cmd_install(config_path: str, command: str, state_path: str) -> None:
139
241
  # REFUSE a symlink — it is someone else's SSOT (an agent-config link). Never clobber.
140
242
  if os.path.islink(config_path):
@@ -294,33 +396,62 @@ def cmd_permission_install(settings_path: str, state_path: str) -> None:
294
396
  # rule, we did not add it, so uninstall must not take it away. On a RE-install the rule on disk
295
397
  # is our own previous write — reading it as "pre-existing" would hand the operator credit for
296
398
  # our entry and strand it forever. So provenance is taken from the first install and carried.
297
- on_disk = ALLOW_RULE in allow # what the file says NOW (may be our own earlier write)
298
- rule_existed = on_disk
399
+ on_disk = {rule: rule in allow for rule in ALLOW_RULES} # what the file says NOW
400
+ # Provenance is PER RULE. A host can easily have granted one of these by hand (agy's own
401
+ # "always allow" prompt persists exactly one rule at a time — that is how an operator ends up
402
+ # owning entwurf_self while entwurf owns entwurf_v2), and a set-wide flag would either strand
403
+ # their rule or take it away. Each rule carries its own answer to "was this yours before us?".
404
+ rules_existed = dict(on_disk)
299
405
  prior = _prior_state(state_path, "managedSettingsPath", settings_path)
300
406
  if prior:
301
- rule_existed = prior.get("ruleExistedBefore", rule_existed)
302
407
  detect_mode = prior.get("detectMode", detect_mode)
303
408
  settings_existed = prior.get("settingsExistedBefore", settings_existed)
304
409
  permissions_existed = prior.get("permissionsExistedBefore", permissions_existed)
305
410
  allow_existed = prior.get("allowExistedBefore", allow_existed)
306
- if ALLOW_RULE not in allow:
307
- allow.append(ALLOW_RULE)
411
+ # Carry recorded provenance forward, branching on the VERSION rather than on which keys
412
+ # happen to be present — a duck-typed read of a malformed state is how a wrong answer here
413
+ # becomes a deleted operator rule at uninstall time. A rule the prior state never described
414
+ # (a tool we only started granting now) keeps the preimage we just read from disk, which is
415
+ # the first honest observation we have of it.
416
+ # The SAME strict parser uninstall uses. A prior state we cannot read is a REFUSAL, not a
417
+ # licence to re-capture: overwriting it would replace the operator's recorded answer with a
418
+ # fresh reading of a file we ourselves wrote, which is precisely what this state file exists
419
+ # to prevent. Only rules the prior state actually RECORDED are carried; a tool we started
420
+ # granting later keeps the preimage read from disk, its first honest observation.
421
+ try:
422
+ _prior_rules, prior_existed = _parse_permission_state(prior)
423
+ except _PermissionStateError as exc:
424
+ _die(4, f"agy-bridge: prior permission install-state is unusable — {exc}. Refusing to "
425
+ "overwrite provenance this entwurf cannot read; inspect it by hand.")
426
+ for rule in ALLOW_RULES:
427
+ if rule in prior_existed:
428
+ rules_existed[rule] = prior_existed[rule]
429
+ for rule in ALLOW_RULES:
430
+ if rule not in allow:
431
+ allow.append(rule)
308
432
 
309
433
  _atomic_write(settings_path, _dump(data))
310
434
 
311
435
  state = {
312
- "schemaVersion": STATE_SCHEMA_VERSION,
436
+ "schemaVersion": PERMISSION_STATE_SCHEMA_VERSION,
313
437
  "managedSettingsPath": os.path.abspath(settings_path),
314
- "rule": ALLOW_RULE,
438
+ "rules": list(ALLOW_RULES),
315
439
  "detectMode": detect_mode,
316
440
  "settingsExistedBefore": settings_existed,
317
441
  "permissionsExistedBefore": permissions_existed,
318
442
  "allowExistedBefore": allow_existed,
319
- "ruleExistedBefore": rule_existed, # true = the operator's rule, not ours to remove
443
+ # true = the operator's rule, not ours to remove
444
+ "rulesExistedBefore": {rule: rules_existed[rule] for rule in ALLOW_RULES},
320
445
  "installedAt": _now(),
321
446
  }
322
447
  _atomic_write(state_path, _dump(state))
323
- sys.stdout.write(f"{'already-present' if on_disk else 'added'} {ALLOW_RULE}\n")
448
+ added = [rule for rule in ALLOW_RULES if not on_disk[rule]]
449
+ present = [rule for rule in ALLOW_RULES if on_disk[rule]]
450
+ if added:
451
+ sys.stdout.write(f"added {' '.join(added)}\n" if not present else
452
+ f"added {' '.join(added)} (already-present {' '.join(present)})\n")
453
+ else:
454
+ sys.stdout.write(f"already-present {' '.join(ALLOW_RULES)}\n")
324
455
 
325
456
 
326
457
  def cmd_permission_uninstall(state_path: str) -> None:
@@ -331,6 +462,17 @@ def cmd_permission_uninstall(state_path: str) -> None:
331
462
  if not isinstance(settings_path, str):
332
463
  _die(4, f"agy-bridge: permission install-state {state_path} has no managedSettingsPath")
333
464
 
465
+ # DECIDE BEFORE MUTATING. Per rule: the operator already had it before us → it is theirs; leave
466
+ # it. Reads a v1 state (single `rule`) as well as v2, so an uninstall from an older install-state
467
+ # still removes exactly what that install added and nothing else.
468
+ #
469
+ # This runs FIRST, ahead of every write and removal, because a refusal has to leave the world
470
+ # untouched. It used to be computed inside the settings-exists branch and then, for a host whose
471
+ # settings file was gone, first reached only in the closing message — AFTER `os.remove(state_path)`
472
+ # had already run. A malformed state on such a host was "refused" with the state file deleted:
473
+ # the one record of what we owed the operator, destroyed by the safety check itself.
474
+ ours = _owned_rules(state)
475
+
334
476
  if os.path.islink(settings_path):
335
477
  _die(3, f"agy-bridge: refusing to uninstall — {settings_path} became a symlink since install "
336
478
  f"(someone else's SSOT now). Resolve by hand.")
@@ -340,9 +482,8 @@ def cmd_permission_uninstall(state_path: str) -> None:
340
482
  perms = data.get("permissions")
341
483
  if isinstance(perms, dict):
342
484
  allow = perms.get("allow")
343
- # The operator already had the rule before us → it is theirs; leave it.
344
- if isinstance(allow, list) and not state.get("ruleExistedBefore", False):
345
- perms["allow"] = [r for r in allow if r != ALLOW_RULE]
485
+ if isinstance(allow, list) and ours:
486
+ perms["allow"] = [r for r in allow if r not in ours]
346
487
  # Drop only the containers WE created, and only while they are empty. An operator
347
488
  # who has since added their own rules keeps their structure untouched.
348
489
  if not state.get("allowExistedBefore", False) and perms["allow"] == []:
@@ -356,7 +497,8 @@ def cmd_permission_uninstall(state_path: str) -> None:
356
497
  _atomic_write(settings_path, _dump(data))
357
498
 
358
499
  os.remove(state_path)
359
- sys.stdout.write(f"uninstalled {ALLOW_RULE} from {settings_path}\n")
500
+ what = " ".join(ours) if ours else "(nothing every rule was already the operator's)"
501
+ sys.stdout.write(f"uninstalled {what} from {settings_path}\n")
360
502
 
361
503
 
362
504
  def cmd_permission_doctor(settings_path: str) -> None:
@@ -376,30 +518,77 @@ def cmd_permission_doctor(settings_path: str) -> None:
376
518
  # Precedence FIRST (Deny > Ask > Allow): a matching rule in a higher list means agy prompts (or
377
519
  # blocks) no matter what our allow says. Reporting `configured` there would be a green light on
378
520
  # a surface that is actually still stopping every call.
379
- for list_name in SHADOWING_LISTS:
380
- rules = perms.get(list_name)
381
- if not isinstance(rules, list):
382
- continue
383
- for rule in rules:
384
- if rule in SHADOWING_RULES:
385
- sys.stdout.write(f"shadowed-by-{list_name} {rule}\n")
386
- return
521
+ # SCOPE is part of the truth. `mcp(*)` and the server-wide `mcp(entwurf-bridge)` shadow every
522
+ # tool we grant; an exact rule like `mcp(entwurf-bridge/entwurf_self)` shadows ONLY that one and
523
+ # the other tools keep working. Both are red — but telling an operator that every entwurf call
524
+ # is blocked when only `entwurf_self` is would send them hunting the wrong thing. Broad first,
525
+ # so a host carrying both is described by the rule that actually covers the most.
526
+ # TWO passes over ALL the shadowing lists, not one pass per list. Broad-most-covering is a
527
+ # statement about the whole file: a deny of one exact tool alongside an ask of the server-wide
528
+ # rule is a host where EVERYTHING is shadowed, and reporting the exact hit merely because `deny`
529
+ # is scanned first would tell the operator "your other grants still work" while agy prompts on
530
+ # all of them. Precedence still decides WHICH list to name once the scope is settled.
531
+ for rule_set, scope in ((BROAD_RULES, "broad"), (ALLOW_RULES, "exact")):
532
+ for list_name in SHADOWING_LISTS:
533
+ rules = perms.get(list_name)
534
+ if not isinstance(rules, list):
535
+ continue
536
+ for rule in rules:
537
+ if rule in rule_set:
538
+ sys.stdout.write(f"shadowed-by-{list_name} {scope} {rule}\n")
539
+ return
387
540
 
388
541
  allow = perms.get("allow")
389
542
  if isinstance(allow, list):
390
- if ALLOW_RULE in allow:
543
+ # EVERY tool we grant must be granted. A partial set is not a working surface: agy stops for
544
+ # a y/n on whichever tool is missing, which is the same "registered and unusable" state the
545
+ # doctor already refuses to call green — it was simply never checked past entwurf_v2.
546
+ if all(rule in allow for rule in ALLOW_RULES):
391
547
  sys.stdout.write("configured\n")
392
548
  return
393
- # No rule of ours, but a BROADER operator rule in the same list already matches our tool.
394
- # agy will not prompt; the call works. Say so — and say whose rule is carrying it, because
395
- # the day the operator narrows that wildcard, our unowned grant disappears with it.
549
+ # Not every rule of ours is here, but a BROADER operator rule in the same list already
550
+ # matches our tools. agy will not prompt; the calls work. Say so — and say whose rule is
551
+ # carrying it, because the day the operator narrows that wildcard, the unowned grant goes.
396
552
  for rule in MATCHING_RULES:
397
- if rule != ALLOW_RULE and rule in allow:
553
+ if rule not in ALLOW_RULES and rule in allow:
398
554
  sys.stdout.write(f"covered-by-allow {rule}\n")
399
555
  return
556
+ missing = [rule for rule in ALLOW_RULES if rule not in allow]
557
+ if len(missing) != len(ALLOW_RULES):
558
+ # Some of ours are present and some are not: name the gap rather than report a flat
559
+ # "nothing granted", which would read as "install never ran" on a half-granted host.
560
+ sys.stdout.write(f"partially-configured {' '.join(missing)}\n")
561
+ return
400
562
  sys.stdout.write("not-configured\n")
401
563
 
402
564
 
565
+ def cmd_permission_state_doctor(state_path: str) -> None:
566
+ """Is the ownership record itself readable? One token line: `absent` / `ok <n>` / `corrupt <why>`.
567
+
568
+ Hard rule 13 — runtime truth and OWNERSHIP truth are separate axes. The settings file can carry
569
+ all three exact rules (runtime perfectly fine, agy prompts on nothing) while the state that says
570
+ WHICH of them are ours is unreadable. The permission doctor reads settings and would call that
571
+ host `configured`; the independent state check read only `managedSettingsPath` and agreed. So a
572
+ corrupt ownership record rounded up to a green overall verdict, and the operator would learn
573
+ about it at uninstall time — the worst possible moment to discover we cannot tell their rules
574
+ from ours. Same parser as install/uninstall, so there is exactly one definition of readable.
575
+ """
576
+ if not os.path.exists(state_path):
577
+ sys.stdout.write("absent\n")
578
+ return
579
+ try:
580
+ state = _load_config(state_path)
581
+ except SystemExit:
582
+ sys.stdout.write("corrupt not valid JSON\n")
583
+ return
584
+ try:
585
+ rules, _existed = _parse_permission_state(state)
586
+ except _PermissionStateError as exc:
587
+ sys.stdout.write(f"corrupt {exc}\n")
588
+ return
589
+ sys.stdout.write(f"ok {len(rules)}\n")
590
+
591
+
403
592
  def _now() -> str:
404
593
  import datetime
405
594
 
@@ -426,6 +615,17 @@ def main(argv: list) -> None:
426
615
  if len(argv) != 3:
427
616
  _die(5, "usage: agy-bridge-config.py doctor-static <config_path>")
428
617
  cmd_doctor_static(argv[2])
618
+ elif sub == "permission-state-doctor":
619
+ if len(argv) != 3:
620
+ _die(5, "usage: agy-bridge-config.py permission-state-doctor <state_path>")
621
+ cmd_permission_state_doctor(argv[2])
622
+ elif sub == "permission-rules":
623
+ if len(argv) != 2:
624
+ _die(5, "usage: agy-bridge-config.py permission-rules")
625
+ # The shell renders these in operator messages. It reads them from HERE rather than
626
+ # repeating the strings, so the rule set has exactly one source and a tool added to
627
+ # ALLOW_RULES can never drift out of what the doctor tells the operator to fix.
628
+ sys.stdout.write(" ".join(ALLOW_RULES) + "\n")
429
629
  elif sub == "permission-install":
430
630
  if len(argv) != 4:
431
631
  _die(5, "usage: agy-bridge-config.py permission-install <settings_path> <state_path>")
@@ -39,12 +39,15 @@ COMMAND="${AGY_BRIDGE_COMMAND:-entwurf-bridge}"
39
39
  STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/agy-bridge"
40
40
  STATE_FILE="$STATE_DIR/install-state.json"
41
41
  # agy's settings.json — the SAME file the statusline adapter owns, but a DIFFERENT element: it owns
42
- # the `statusLine` subtree, we own one string in `permissions.allow`. Element-level ownership on
42
+ # the `statusLine` subtree, we own a few strings in `permissions.allow`. Element-level ownership on
43
43
  # both sides is what lets two adapters share a file without either clobbering the other (or the
44
44
  # operator's own rules). Tracked in its own state file so each half has an honest inverse.
45
45
  SETTINGS_FILE="${AGY_SETTINGS_CONFIG:-$HOME/.gemini/antigravity-cli/settings.json}"
46
46
  PERMISSION_STATE_FILE="$STATE_DIR/permission-state.json"
47
- ALLOW_RULE="mcp(entwurf-bridge/entwurf_v2)"
47
+ # One narrow allow rule per tool the NORMAL agy workflow calls (all five MCP tools are visible to
48
+ # the model; these are the ones auto-granted). Read from the config engine, never retyped here:
49
+ # a second copy of this list is a drift bug waiting for the next tool we ship.
50
+ ALLOW_RULES="$(python3 "$CONFIG_PY" permission-rules)"
48
51
 
49
52
  log() { printf '%s\n' "$*"; }
50
53
  fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
@@ -131,8 +134,8 @@ install_permission() {
131
134
  set -e
132
135
  case "$rc" in
133
136
  0) log " permission: ${out} in $SETTINGS_FILE" ;;
134
- 3) fail "permission refused (symlink) — $SETTINGS_FILE is someone else's SSOT; left intact. The MCP server is registered but NOT granted, so agy would prompt on every entwurf_v2 call. Add '$ALLOW_RULE' to permissions.allow there, or replace the symlink with a regular file and re-run." ;;
135
- 4) fail "permission invalid JSON — $SETTINGS_FILE could not be parsed; left intact. The MCP server is registered but NOT granted, so agy would prompt on every entwurf_v2 call. Repair that file, then re-run." ;;
137
+ 3) fail "permission refused (symlink) — $SETTINGS_FILE is someone else's SSOT; left intact. The MCP server is registered but NOT granted, so agy would prompt on every entwurf tool call. Add these to permissions.allow there: $ALLOW_RULES — or replace the symlink with a regular file and re-run." ;;
138
+ 4) fail "permission invalid JSON — $SETTINGS_FILE could not be parsed; left intact. The MCP server is registered but NOT granted, so agy would prompt on every entwurf tool call. Repair that file, then re-run." ;;
136
139
  *) fail "permission could not be granted (rc=$rc) — ${out}" ;;
137
140
  esac
138
141
  }
@@ -162,7 +165,7 @@ do_uninstall() {
162
165
  case "$rc" in
163
166
  0) log " permission: ${out}" ;;
164
167
  2) : ;; # never granted → nothing to undo (idempotent)
165
- 3) fail "permission refused (symlink) — $SETTINGS_FILE became a symlink since install; our rule is STILL THERE. Remove '$ALLOW_RULE' from permissions.allow by hand at the link target." ;;
168
+ 3) fail "permission refused (symlink) — $SETTINGS_FILE became a symlink since install; our rules are STILL THERE. Remove ours from permissions.allow by hand at the link target: $ALLOW_RULES" ;;
166
169
  *) fail "permission could not be revoked (rc=$rc) — ${out}" ;;
167
170
  esac
168
171
  }
@@ -209,7 +212,14 @@ doctor_permission() {
209
212
  status="$(python3 "$CONFIG_PY" permission-doctor "$SETTINGS_FILE")"
210
213
  case "$status" in
211
214
  configured)
212
- log " permission ($SETTINGS_FILE): allow → '$ALLOW_RULE' (agy calls entwurf_v2 without prompting)"; return 0 ;;
215
+ log " permission ($SETTINGS_FILE): allow → $ALLOW_RULES (agy calls every entwurf tool without prompting)"; return 0 ;;
216
+ partially-configured\ *)
217
+ # SOME of our tools are granted and some are not. agy stops for a y/n on exactly the missing
218
+ # ones, so the bridge half-works — the most confusing shape there is ("it worked yesterday
219
+ # when I sent a message, why is it asking now?"). Name the missing rules, never round up to
220
+ # configured because the tool the operator happened to try first was granted.
221
+ log " permission ($SETTINGS_FILE): DRIFT — granted, but NOT for: ${status#partially-configured } . agy defaults mcp to Ask, so every call to those tools prompts. Fix: ./run.sh install-agy-bridge"
222
+ return 1 ;;
213
223
  covered-by-allow\ *)
214
224
  # The operator's own broader rule already grants our tool, so entwurf_v2 does NOT prompt —
215
225
  # calling this DRIFT would be a false red about a working surface. But it is THEIR rule, not
@@ -228,27 +238,45 @@ except Exception:
228
238
  sys.exit(1)
229
239
  p = s.get("managedSettingsPath")
230
240
  same = isinstance(p, str) and os.path.isabs(p) and os.path.abspath(p) == os.path.abspath(sys.argv[2])
231
- sys.exit(0 if same and s.get("ruleExistedBefore") is False else 1)' "$PERMISSION_STATE_FILE" "$SETTINGS_FILE"; then
232
- log " permission ($SETTINGS_FILE): DRIFT entwurf installed '$ALLOW_RULE' here and it is now GONE. Your '$covering' still covers the tool, so agy does not prompt today — but the grant entwurf owns (and repairs) no longer exists. Fix: ./run.sh install-agy-bridge"
241
+ # Owned = WE added at least one rule. schemaVersion 2 answers per rule (`rulesExistedBefore`);
242
+ # schemaVersion 1 had a single `ruleExistedBefore`. Read both an old state file must not silently
243
+ # report "we own nothing" and turn owned drift into a green NOTE.
244
+ existed = s.get("rulesExistedBefore")
245
+ if isinstance(existed, dict):
246
+ owned = any(v is False for v in existed.values())
247
+ else:
248
+ owned = s.get("ruleExistedBefore") is False
249
+ sys.exit(0 if same and owned else 1)' "$PERMISSION_STATE_FILE" "$SETTINGS_FILE"; then
250
+ log " permission ($SETTINGS_FILE): DRIFT — entwurf installed its allow rules here ($ALLOW_RULES) and they are now GONE. Your '$covering' still covers the tools, so agy does not prompt today — but the grant entwurf owns (and repairs) no longer exists. Fix: ./run.sh install-agy-bridge"
233
251
  return 1
234
252
  fi
235
- log " permission ($SETTINGS_FILE): NOTE — we own no rule here, but your '$covering' in permissions.allow already matches $ALLOW_RULE, so agy calls entwurf_v2 without prompting. That grant is YOURS: narrow '$covering' and entwurf_v2 starts prompting again. Run ./run.sh install-agy-bridge if you want the narrow rule owned (and repaired) by entwurf."
253
+ log " permission ($SETTINGS_FILE): NOTE — we own no rule here, but your '$covering' in permissions.allow already matches our tools, so agy calls them without prompting. That grant is YOURS: narrow '$covering' and every entwurf tool starts prompting again. Run ./run.sh install-agy-bridge if you want the narrow rules ($ALLOW_RULES) owned (and repaired) by entwurf."
236
254
  return 0 ;;
237
255
  not-configured|absent)
238
- local what="'$ALLOW_RULE' NOT granted"
256
+ local what="none of $ALLOW_RULES granted"
239
257
  [ "$status" = absent ] && what="settings file absent, so no grant"
240
258
  if [ "$installed" -eq 1 ]; then
241
- log " permission ($SETTINGS_FILE): DRIFT — $what, but the bridge IS installed. agy defaults mcp to Ask, so EVERY entwurf_v2 call prompts: the server is registered and unusable without a y/n. Fix: ./run.sh install-agy-bridge"
259
+ log " permission ($SETTINGS_FILE): DRIFT — $what, but the bridge IS installed. agy defaults mcp to Ask, so EVERY entwurf tool call prompts: the server is registered and unusable without a y/n. Fix: ./run.sh install-agy-bridge"
242
260
  return 1
243
261
  fi
244
262
  log " permission ($SETTINGS_FILE): $what (bridge not installed here — nothing to grant yet)"; return 0 ;;
245
263
  invalid-json)
246
264
  log " permission ($SETTINGS_FILE): INVALID JSON — cannot read the permission engine's config"; return 1 ;;
247
265
  shadowed-by-*)
248
- local list rule
266
+ local list rest scope rule
249
267
  list="${status%% *}"; list="${list#shadowed-by-}"
250
- rule="${status#* }"
251
- log " permission ($SETTINGS_FILE): SHADOWED — your '$list' list carries '$rule', and agy evaluates Deny > Ask > Allow, so it OVERRIDES our allow of '$ALLOW_RULE'. agy will keep prompting (or blocking) on every entwurf_v2 call until that rule is narrowed."; return 1 ;;
268
+ rest="${status#* }" # "<scope> <rule>"
269
+ scope="${rest%% *}"
270
+ rule="${rest#* }"
271
+ # Say what is actually blocked. A broad rule takes the whole server; an exact rule takes ONE
272
+ # tool and the rest keep working — reporting that as a total outage sends the operator hunting
273
+ # the wrong config. Red either way: a tool we grant that agy still stops on is not green.
274
+ if [ "$scope" = broad ]; then
275
+ log " permission ($SETTINGS_FILE): SHADOWED — your '$list' list carries '$rule', and agy evaluates Deny > Ask > Allow, so it OVERRIDES our allow of $ALLOW_RULES. agy will keep prompting (or blocking) on EVERY entwurf tool call until that rule is narrowed."
276
+ else
277
+ log " permission ($SETTINGS_FILE): SHADOWED — your '$list' list carries '$rule', and agy evaluates Deny > Ask > Allow, so it OVERRIDES our allow of that ONE tool. Our other grants still work; agy will keep prompting (or blocking) on that tool alone until the rule is removed."
278
+ fi
279
+ return 1 ;;
252
280
  *) log " permission ($SETTINGS_FILE): unexpected status '$status'"; return 1 ;;
253
281
  esac
254
282
  }
@@ -323,6 +351,23 @@ do_doctor() {
323
351
  elif [ "$pmanaged" != "$expected_settings" ]; then
324
352
  log " state: FOREIGN TARGET (permission) — permission-state manages '$pmanaged', but agy reads '$expected_settings'. The grant recorded there is not the grant on this host."
325
353
  hard_fail=1
354
+ else
355
+ # Pointing at the right file is not the same as being READABLE. The schema and the per-rule
356
+ # provenance are what tell OUR grants from the operator's; without them the inverse cannot run
357
+ # and we would only find out at uninstall time. Runtime can be perfect (all rules present, agy
358
+ # prompting on nothing) while this axis is broken — hard rule 13 keeps them separate, so this
359
+ # is red on its own evidence and never rounded up by a green permission line above.
360
+ local pstate
361
+ pstate="$(python3 "$CONFIG_PY" permission-state-doctor "$PERMISSION_STATE_FILE")"
362
+ case "$pstate" in
363
+ ok\ *) : ;;
364
+ corrupt\ *)
365
+ log " state: CORRUPT (permission) — the ownership record cannot be read: ${pstate#corrupt }. Runtime may be fine, but entwurf can no longer tell ITS grants from yours, so uninstall will refuse. Fix: ./run.sh install-agy-bridge (or delete $PERMISSION_STATE_FILE by hand once you have checked permissions.allow yourself)."
366
+ hard_fail=1 ;;
367
+ *)
368
+ log " state: unexpected permission-state verdict '$pstate'"
369
+ hard_fail=1 ;;
370
+ esac
326
371
  fi
327
372
  fi
328
373
 
@@ -18,6 +18,7 @@ import { strict as assert } from "node:assert";
18
18
  import { mkdtempSync, writeFileSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
21
22
  import type { Context } from "@earendil-works/pi-ai";
22
23
  import {
23
24
  buildPiContextAugment,
@@ -32,6 +33,7 @@ import { buildClaudeSessionMeta } from "../pi-extensions/lib/acp/tool-surface.ts
32
33
  import { ENTWURF_PROJECT_CONTEXT_OPEN_TAG } from "../protocol.js";
33
34
 
34
35
  const tmp = mkdtempSync(join(tmpdir(), "acp-carrier-augment-"));
36
+ const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
35
37
  const BRIDGE_MARK = "operating through entwurf";
36
38
 
37
39
  // ===========================================================================
@@ -285,7 +287,37 @@ function ctxWith(firstUser: string): Context {
285
287
  writeFileSync(join(bigHome, "AGENTS.md"), "X".repeat(80 * 1024));
286
288
  const big = buildPiContextAugment({ backend: "claude", cwd: tmp, mcpServerNames: [], homeDir: bigHome });
287
289
  assert.ok(Buffer.byteLength(big, "utf8") <= 50 * 1024, "augment is truncated to the 50KB cap");
288
- assert.match(big, /context augment truncated to \d+ bytes/, "truncation leaves an honest marker");
290
+ assert.match(
291
+ big,
292
+ /context augment truncated to \d+ bytes/,
293
+ "truncation leaves an honest marker [QK:AUGMENT-TRUNC-MARKER]",
294
+ );
295
+ }
296
+
297
+ // ===========================================================================
298
+ // 9) The shipped repo AGENTS.md fits with a realistic operator-global budget.
299
+ // This guards the real 2026-07-27 failure: project AGENTS.md alone exceeded
300
+ // the cap, tail-cutting its own later rules plus Current date/cwd. The cap
301
+ // remains an honest fallback; the maintained package prompt must not hit it.
302
+ // ===========================================================================
303
+ {
304
+ const budgetHome = mkdtempSync(join(tmpdir(), "acp-budget-home-"));
305
+ writeFileSync(join(budgetHome, "AGENTS.md"), "H".repeat(12 * 1024));
306
+ const actual = buildPiContextAugment({
307
+ backend: "claude",
308
+ cwd: REPO_DIR,
309
+ mcpServerNames: ["entwurf-bridge"],
310
+ homeDir: budgetHome,
311
+ });
312
+ assert.doesNotMatch(
313
+ actual,
314
+ /context augment truncated/,
315
+ "repo AGENTS.md + 12KB global baseline fits without truncation [QK:AUGMENT-BUDGET-FITS]",
316
+ );
317
+ assert.ok(
318
+ actual.includes(`Current working directory: ${REPO_DIR}`),
319
+ "non-truncated augment retains the trailing cwd fact",
320
+ );
289
321
  }
290
322
 
291
323
  console.log(
@@ -293,5 +325,5 @@ console.log(
293
325
  "empty/whitespace/missing → null, shipped-default → non-empty v1 lever (preset replaced), carrier absent → no _meta.systemPrompt key, carrier change → " +
294
326
  "signature change (stable carrier → stable signature); augment: prepended on `new` only (reuse delta has none), " +
295
327
  "wire-only so it never enters contextMessageSignatures, entwurf cwd/AGENTS.md de-dup (present → drop only that " +
296
- "section, home kept; absent → kept), day-granularity date, 50KB truncation marker",
328
+ "section, home kept; absent → kept), day-granularity date, 50KB truncation marker, shipped AGENTS budget",
297
329
  );