@team-semicolon/semicolony-cli 4.18.104 → 4.18.106

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.
@@ -0,0 +1,318 @@
1
+ -- 205_action_item_credential_scope.sql
2
+ --
3
+ -- Open the /action/* Gateway lane to the credentials that have to reach it.
4
+ --
5
+ -- Plan 2 tasks 1 and 2 built ActionService and the five /action routes. Every
6
+ -- one of them gates on `action:read` or `action:write` and accepts either a
7
+ -- `worker` credential (the human lane) or a `worker-agent-actor` credential
8
+ -- (the agent lane, semi and colony). No credential in the fleet holds either
9
+ -- scope, so the whole lane answers 403 today. This migration issues the two
10
+ -- scopes and moves both scope contracts that would otherwise refuse them.
11
+ --
12
+ -- Measured live state before this migration (2026-08-13, from the production
13
+ -- schema dump plus the operator's credential read). The two scopes below are
14
+ -- what this file adds; nothing else may change:
15
+ --
16
+ -- metadata->>'device_id' scopes (count)
17
+ -- ---------------------- -------------------------------------------------
18
+ -- reus-macmini kb:read, feedback:write, skills:read, skills:write,
19
+ -- worker:release:read, worker:release:report,
20
+ -- workspace:assets-read, workspace:assets-write (8)
21
+ -- semicolony-agent the same eight plus meetings:write (9)
22
+ -- roki-pc kb:read, skills:read, skills:write,
23
+ -- worker:release:read, worker:release:report (5)
24
+ -- jay-pc kb:read, skills:read, skills:write (3)
25
+ --
26
+ -- worker-agent-actor semi and colony
27
+ -- kb:read, feedback:write, decision:write,
28
+ -- note:write (4)
29
+ --
30
+ -- The worker amendment is therefore additive per credential and never a
31
+ -- wholesale array write. reus-macmini is the only holder of
32
+ -- `workspace:assets-write` and semicolony-agent the only holder of
33
+ -- `meetings:write`; a replacement array sized to the plan's own list would
34
+ -- drop one of those silently, and neither is recoverable from this file.
35
+ --
36
+ -- The two device-bound credentials that must NOT be reached: the
37
+ -- `worker-agent-host` credential (release-only, two scopes, no device_id in
38
+ -- its metadata) and any revoked or expired row. Both are excluded by
39
+ -- predicate, and section 5 asserts it rather than assuming it.
40
+ --
41
+ -- Deploy order. The Gateway must ship before this migration is applied:
42
+ -- `tenant-credentials.ts` gates the actor scope array order-exactly, and
43
+ -- section 4 rewrites that array. The Gateway build that accompanies this
44
+ -- migration accepts the 186 (3-scope), 194 (4-scope) and 205 (6-scope)
45
+ -- contracts, so Gateway-then-migration is safe in either direction and
46
+ -- migration-then-Gateway locks both actors out of every lane they own.
47
+ --
48
+ -- The migration runner owns BEGIN/COMMIT and retargets the `semicolony.`
49
+ -- qualifier to the active schema.
50
+
51
+ -- ---------------------------------------------------------------------------
52
+ -- 1. Let the two governed worker-credential entry points express the new
53
+ -- scopes.
54
+ --
55
+ -- `issue_worker_gateway_credential` (rotation) and
56
+ -- `amend_worker_gateway_credential_scopes` (in-place amendment) each carry
57
+ -- an identical literal scope allowlist and reject anything outside it.
58
+ -- Backfilling section 2 without widening them would mean the next rotation
59
+ -- of any of the four devices -- worker credentials expire within 90 days by
60
+ -- construction -- either fails outright or silently drops the two scopes.
61
+ -- 200_meeting_credential_scope.sql widened both entry points for
62
+ -- `meetings:write` for exactly this reason; skipping it here would repeat
63
+ -- the `workspace:assets-write` drift that 200's header documents.
64
+ --
65
+ -- The widening rewrites only the allowlist inside the installed definition
66
+ -- (the 187/194 pattern). Copying the ~10KB bodies would silently revert any
67
+ -- correction they have received since the dump was taken.
68
+ -- ---------------------------------------------------------------------------
69
+
70
+ DO $worker_scope_allowlist$
71
+ DECLARE
72
+ v_targets constant text[] := ARRAY[
73
+ 'semicolony.issue_worker_gateway_credential(uuid,text,text,text,text,text[],text,integer,jsonb)',
74
+ 'semicolony.amend_worker_gateway_credential_scopes(uuid,text[],text,text)'
75
+ ];
76
+ -- The last element of the allowlist plus its closing bracket. Anchoring on
77
+ -- the closing bracket is what makes this unambiguous: 'worker:release:report'
78
+ -- also appears in other functions, but only here immediately before `]`.
79
+ v_old constant text := E' ''worker:release:report''\n ]::text[])';
80
+ v_new constant text := E' ''worker:release:report'',\n'
81
+ || E' ''action:read'',\n'
82
+ || E' ''action:write''\n'
83
+ || E' ]::text[])';
84
+ v_target text;
85
+ v_definition text;
86
+ v_old_occurrences integer;
87
+ BEGIN
88
+ FOREACH v_target IN ARRAY v_targets LOOP
89
+ SELECT pg_get_functiondef(v_target::regprocedure::oid) INTO STRICT v_definition;
90
+
91
+ v_old_occurrences :=
92
+ (char_length(v_definition) - char_length(replace(v_definition, v_old, '')))
93
+ / char_length(v_old);
94
+
95
+ -- Already widened: re-running must be a no-op, not an exception.
96
+ IF v_old_occurrences = 0 AND position(v_new IN v_definition) > 0 THEN
97
+ CONTINUE;
98
+ END IF;
99
+ IF v_old_occurrences <> 1 THEN
100
+ RAISE EXCEPTION 'unexpected worker credential scope allowlist in %', v_target;
101
+ END IF;
102
+
103
+ EXECUTE replace(v_definition, v_old, v_new);
104
+ END LOOP;
105
+ END;
106
+ $worker_scope_allowlist$;
107
+
108
+ -- ---------------------------------------------------------------------------
109
+ -- 2. Grant the two scopes to the device-bound worker credentials.
110
+ --
111
+ -- Keyed on `metadata->>'device_id'` being present, so the actor and host
112
+ -- credentials -- which carry `installation_id` instead -- cannot be reached
113
+ -- from here. Each scope is appended only where it is absent, so a re-run
114
+ -- changes nothing and no existing scope is rewritten.
115
+ -- ---------------------------------------------------------------------------
116
+
117
+ UPDATE semicolony.gateway_credentials AS gc
118
+ SET scopes = gc.scopes
119
+ || CASE WHEN 'action:read' = ANY(gc.scopes)
120
+ THEN ARRAY[]::text[] ELSE ARRAY['action:read']::text[] END
121
+ || CASE WHEN 'action:write' = ANY(gc.scopes)
122
+ THEN ARRAY[]::text[] ELSE ARRAY['action:write']::text[] END
123
+ WHERE gc.status = 'active'
124
+ AND gc.revoked_at IS NULL
125
+ AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
126
+ AND gc.metadata ->> 'credential_kind' = 'worker'
127
+ AND gc.metadata ->> 'device_id' IS NOT NULL
128
+ AND NOT (gc.scopes @> ARRAY['action:read', 'action:write']::text[]);
129
+
130
+ -- ---------------------------------------------------------------------------
131
+ -- 3. Widen the actor scope contract from four scopes to six.
132
+ --
133
+ -- The worker contract for action items was decided as human AND agent, so
134
+ -- semi and colony must be able to read, create and close their own items.
135
+ -- They cannot today: the actor scope array is compared by exact equality at
136
+ -- three sites, and a credential carrying anything else is refused.
137
+ --
138
+ -- All three sites, measured against the live schema (2026-08-13):
139
+ -- * issue_worker_agent_actor_credential -- the array written at issuance
140
+ -- * record_worker_agent_decision -- `gc.scopes = ARRAY[...]`
141
+ -- * record_worker_agent_note -- `gc.scopes = ARRAY[...]`
142
+ -- Missing any one of them does not fail loudly; it silently refuses the
143
+ -- credential the next time that lane is used. 194 moved the first two when
144
+ -- it added `note:write` and installed the third.
145
+ --
146
+ -- Order. The array is order-exact, so the two scopes are APPENDED, in
147
+ -- migration order (186 -> 194 -> 205) and read-before-write. Appending is
148
+ -- the only change that cannot reinterpret an existing index, which is what
149
+ -- lets the Gateway keep accepting the older contracts by length. The same
150
+ -- order is mirrored in `tenant-credentials.ts`.
151
+ -- ---------------------------------------------------------------------------
152
+
153
+ DO $actor_scope_widen$
154
+ DECLARE
155
+ v_targets constant text[] := ARRAY[
156
+ 'semicolony.issue_worker_agent_actor_credential(uuid,text,text,text,text,integer)',
157
+ 'semicolony.record_worker_agent_decision(uuid,uuid,text,text,text,text)',
158
+ 'semicolony.record_worker_agent_note(uuid,text,text,text,text)'
159
+ ];
160
+ v_old constant text :=
161
+ 'ARRAY[''kb:read'', ''feedback:write'', ''decision:write'', ''note:write'']::text[]';
162
+ v_new constant text :=
163
+ 'ARRAY[''kb:read'', ''feedback:write'', ''decision:write'', ''note:write'', '
164
+ || '''action:read'', ''action:write'']::text[]';
165
+ v_target text;
166
+ v_definition text;
167
+ v_old_occurrences integer;
168
+ BEGIN
169
+ FOREACH v_target IN ARRAY v_targets LOOP
170
+ SELECT pg_get_functiondef(v_target::regprocedure::oid) INTO STRICT v_definition;
171
+
172
+ v_old_occurrences :=
173
+ (char_length(v_definition) - char_length(replace(v_definition, v_old, '')))
174
+ / char_length(v_old);
175
+
176
+ IF v_old_occurrences = 0 AND position(v_new IN v_definition) > 0 THEN
177
+ CONTINUE;
178
+ END IF;
179
+ IF v_old_occurrences <> 1 THEN
180
+ RAISE EXCEPTION 'unexpected Worker-agent actor scope definition in %', v_target;
181
+ END IF;
182
+
183
+ EXECUTE replace(v_definition, v_old, v_new);
184
+ END LOOP;
185
+ END;
186
+ $actor_scope_widen$;
187
+
188
+ -- ---------------------------------------------------------------------------
189
+ -- 4. Move the two live actor credentials onto the widened contract.
190
+ --
191
+ -- Unlike section 2 this is a whole-array write, and it has to be: the three
192
+ -- sites above compare by equality, so the stored array must be exactly the
193
+ -- contract. It is safe here for the same reason -- the actor array is
194
+ -- closed and fully determined -- and unsafe for the worker credentials in
195
+ -- section 2, whose arrays differ per device. The join reproduces 194's
196
+ -- binding predicate so nothing outside the two bound actors is reachable.
197
+ -- ---------------------------------------------------------------------------
198
+
199
+ UPDATE semicolony.gateway_credentials AS gc
200
+ SET scopes = ARRAY[
201
+ 'kb:read', 'feedback:write', 'decision:write', 'note:write',
202
+ 'action:read', 'action:write'
203
+ ]::text[]
204
+ FROM semicolony.worker_agent_actor_bindings AS binding
205
+ WHERE binding.gateway_credential_id = gc.id
206
+ AND binding.installation_id = 'hermes-principals@hermes'
207
+ AND binding.profile_id = 'semo-ops-worker-agent'
208
+ AND binding.runtime_kind = 'hermes-cli'
209
+ AND binding.actor_id IN ('semi', 'colony')
210
+ AND binding.status = 'active'
211
+ AND gc.status = 'active'
212
+ AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
213
+ AND gc.metadata ->> 'credential_kind' = 'worker-agent-actor'
214
+ AND gc.metadata ->> 'actor_id' = binding.actor_id
215
+ AND gc.metadata ->> 'installation_id' = binding.installation_id
216
+ AND gc.metadata ->> 'profile_id' = binding.profile_id
217
+ AND gc.metadata ->> 'runtime_kind' = binding.runtime_kind
218
+ AND gc.metadata ->> 'runtime_profile' = binding.runtime_profile
219
+ AND gc.scopes
220
+ IS DISTINCT FROM
221
+ ARRAY[
222
+ 'kb:read', 'feedback:write', 'decision:write', 'note:write',
223
+ 'action:read', 'action:write'
224
+ ]::text[];
225
+
226
+ -- ---------------------------------------------------------------------------
227
+ -- 5. Assert the reach, in both directions.
228
+ --
229
+ -- Under-reach (the device filter matched nothing) and over-reach (the
230
+ -- release-only host credential picked up write authority) both fail loudly
231
+ -- here rather than being discovered from a 403 or from an unexpected host
232
+ -- writing an action item.
233
+ -- ---------------------------------------------------------------------------
234
+
235
+ DO $verify_action_scopes$
236
+ DECLARE
237
+ v_worker_with integer;
238
+ v_worker_without integer;
239
+ v_actor_stale integer;
240
+ v_stray integer;
241
+ v_duplicated integer;
242
+ BEGIN
243
+ SELECT
244
+ count(*) FILTER (
245
+ WHERE gc.scopes @> ARRAY['action:read', 'action:write']::text[]
246
+ ),
247
+ count(*) FILTER (
248
+ WHERE NOT (gc.scopes @> ARRAY['action:read', 'action:write']::text[])
249
+ )
250
+ INTO v_worker_with, v_worker_without
251
+ FROM semicolony.gateway_credentials AS gc
252
+ WHERE gc.status = 'active'
253
+ AND gc.revoked_at IS NULL
254
+ AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
255
+ AND gc.metadata ->> 'credential_kind' = 'worker'
256
+ AND gc.metadata ->> 'device_id' IS NOT NULL;
257
+
258
+ IF v_worker_without <> 0 THEN
259
+ RAISE EXCEPTION
260
+ 'expected every active device-bound worker credential to hold the action scopes, % still do not',
261
+ v_worker_without;
262
+ END IF;
263
+ -- Four devices were measured. More is fine (a device onboarded since the
264
+ -- measurement is covered by the same predicate); fewer means the filter
265
+ -- matched something other than the fleet.
266
+ IF v_worker_with < 4 THEN
267
+ RAISE EXCEPTION
268
+ 'expected at least the 4 measured device-bound worker credentials, found %',
269
+ v_worker_with;
270
+ END IF;
271
+
272
+ SELECT count(*)
273
+ INTO v_actor_stale
274
+ FROM semicolony.gateway_credentials AS gc
275
+ JOIN semicolony.worker_agent_actor_bindings AS binding
276
+ ON binding.gateway_credential_id = gc.id
277
+ AND binding.status = 'active'
278
+ WHERE gc.status = 'active'
279
+ AND gc.revoked_at IS NULL
280
+ AND coalesce(gc.expires_at, 'infinity'::timestamptz) > clock_timestamp()
281
+ AND gc.metadata ->> 'credential_kind' = 'worker-agent-actor'
282
+ AND gc.scopes IS DISTINCT FROM ARRAY[
283
+ 'kb:read', 'feedback:write', 'decision:write', 'note:write',
284
+ 'action:read', 'action:write'
285
+ ]::text[];
286
+ IF v_actor_stale <> 0 THEN
287
+ RAISE EXCEPTION
288
+ '% active actor credential(s) left on the old contract; section 3 has made them unusable',
289
+ v_actor_stale;
290
+ END IF;
291
+
292
+ SELECT count(*)
293
+ INTO v_stray
294
+ FROM semicolony.gateway_credentials AS gc
295
+ WHERE gc.scopes && ARRAY['action:read', 'action:write']::text[]
296
+ AND gc.metadata ->> 'credential_kind' <> 'worker-agent-actor'
297
+ AND NOT (
298
+ gc.metadata ->> 'credential_kind' = 'worker'
299
+ AND gc.metadata ->> 'device_id' IS NOT NULL
300
+ );
301
+ IF v_stray <> 0 THEN
302
+ RAISE EXCEPTION 'action scopes reached % unintended credential(s)', v_stray;
303
+ END IF;
304
+
305
+ -- Scoped to what this file writes. A legacy duplicate elsewhere is not this
306
+ -- migration's to fail on, but a duplicate here means the append ran twice.
307
+ SELECT count(*)
308
+ INTO v_duplicated
309
+ FROM semicolony.gateway_credentials AS gc
310
+ WHERE gc.scopes && ARRAY['action:read', 'action:write']::text[]
311
+ AND cardinality(gc.scopes) <> (
312
+ SELECT count(DISTINCT scope)::integer FROM unnest(gc.scopes) AS s(scope)
313
+ );
314
+ IF v_duplicated <> 0 THEN
315
+ RAISE EXCEPTION '% credential(s) carry a duplicated scope', v_duplicated;
316
+ END IF;
317
+ END;
318
+ $verify_action_scopes$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semicolony-cli",
3
- "version": "4.18.104",
3
+ "version": "4.18.106",
4
4
  "description": "SemiColony CLI - AI operations and agent orchestration installer",
5
5
  "main": "dist/bundle.js",
6
6
  "bin": {