@team-semicolon/semicolony-cli 4.18.89 → 4.18.91

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,157 @@
1
+ -- 190_worker_credential_scope_amendment.sql
2
+ --
3
+ -- Amend a worker credential's scopes without reissuing it.
4
+ --
5
+ -- Until now the only governed way to change a scope was
6
+ -- `issue_worker_gateway_credential`, which mints a new credential and requires
7
+ -- the target device to be active, doctor-passing, and seen within 24 hours.
8
+ -- That is the right bar for handing out a new secret. It is the wrong bar for
9
+ -- adjusting what an existing secret may do: on 2026-08-06 two workers needed
10
+ -- `skills:write` after skill staging went lease-free, both devices had been
11
+ -- offline for over a week, and the only way through was a direct UPDATE that
12
+ -- bypassed the operator boundary entirely and left no issuance audit.
13
+ --
14
+ -- This function closes that gap. It mints no secret, so it does not demand a
15
+ -- fresh device binding; it re-validates every scope invariant the issuance
16
+ -- path enforces, and every amendment is recorded append-only.
17
+ --
18
+ -- Deliberately kept from the issuance path:
19
+ -- * the exact scope allowlist, no duplicates, non-empty
20
+ -- * `skills:write` requires `skills:read`
21
+ -- * workspace scopes must exactly match the credential's workspace grants
22
+ -- * worker credentials only, active and unexpired
23
+ --
24
+ -- Deliberately dropped: device liveness. Amending cannot leak a new secret,
25
+ -- and the credential remains revocable at any time.
26
+ --
27
+ -- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
28
+ -- retargets the `semicolony.` qualifier to the active schema.
29
+
30
+ CREATE TABLE IF NOT EXISTS semicolony.gateway_credential_scope_amendments (
31
+ amendment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
32
+ credential_id uuid NOT NULL REFERENCES semicolony.gateway_credentials (id),
33
+ previous_scopes text[] NOT NULL,
34
+ next_scopes text[] NOT NULL,
35
+ reason text NOT NULL CHECK (NULLIF(btrim(reason), '') IS NOT NULL),
36
+ amended_by text NOT NULL CHECK (NULLIF(btrim(amended_by), '') IS NOT NULL),
37
+ amended_at timestamptz NOT NULL DEFAULT clock_timestamp()
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS gateway_credential_scope_amendments_lookup
41
+ ON semicolony.gateway_credential_scope_amendments (credential_id, amended_at DESC);
42
+
43
+ CREATE OR REPLACE FUNCTION semicolony.reject_scope_amendment_mutation()
44
+ RETURNS trigger
45
+ LANGUAGE plpgsql
46
+ AS $function$
47
+ BEGIN
48
+ RAISE EXCEPTION 'scope amendments are append-only' USING ERRCODE = '22023';
49
+ END
50
+ $function$;
51
+
52
+ DROP TRIGGER IF EXISTS protect_gateway_credential_scope_amendments
53
+ ON semicolony.gateway_credential_scope_amendments;
54
+ CREATE TRIGGER protect_gateway_credential_scope_amendments
55
+ BEFORE UPDATE OR DELETE ON semicolony.gateway_credential_scope_amendments
56
+ FOR EACH ROW EXECUTE FUNCTION semicolony.reject_scope_amendment_mutation();
57
+
58
+ CREATE OR REPLACE FUNCTION semicolony.amend_worker_gateway_credential_scopes(
59
+ p_credential_id uuid,
60
+ p_scopes text[],
61
+ p_amended_by text,
62
+ p_reason text
63
+ )
64
+ RETURNS uuid
65
+ LANGUAGE plpgsql
66
+ SECURITY DEFINER
67
+ SET search_path = pg_catalog
68
+ AS $function$
69
+ DECLARE
70
+ v_amendment_id uuid;
71
+ v_previous_scopes text[];
72
+ v_metadata jsonb;
73
+ v_has_credential_grant boolean;
74
+ v_has_asset_grant boolean;
75
+ BEGIN
76
+ IF p_credential_id IS NULL THEN
77
+ RAISE EXCEPTION 'credential id is required' USING ERRCODE = '22023';
78
+ END IF;
79
+ IF NULLIF(btrim(coalesce(p_amended_by, '')), '') IS NULL
80
+ OR NULLIF(btrim(coalesce(p_reason, '')), '') IS NULL THEN
81
+ RAISE EXCEPTION 'amended_by and reason are required' USING ERRCODE = '22023';
82
+ END IF;
83
+
84
+ -- Same scope contract as issuance. Kept literal rather than shared so a
85
+ -- change to one is a visible, reviewed change to the other.
86
+ IF p_scopes IS NULL OR cardinality(p_scopes) = 0
87
+ OR EXISTS (
88
+ SELECT 1
89
+ FROM unnest(p_scopes) AS requested(scope)
90
+ WHERE requested.scope IS NULL
91
+ OR requested.scope <> ALL (ARRAY[
92
+ 'kb:read',
93
+ 'feedback:write',
94
+ 'skills:read',
95
+ 'skills:write',
96
+ 'workspace:credentials-read',
97
+ 'workspace:assets-read',
98
+ 'worker:release:read',
99
+ 'worker:release:report'
100
+ ]::text[])
101
+ )
102
+ OR cardinality(p_scopes) <> (
103
+ SELECT count(DISTINCT requested.scope)::integer FROM unnest(p_scopes) AS requested(scope)
104
+ )
105
+ OR ('skills:write' = ANY(p_scopes) AND NOT ('skills:read' = ANY(p_scopes))) THEN
106
+ RAISE EXCEPTION 'worker credential scopes are not permitted' USING ERRCODE = '22023';
107
+ END IF;
108
+
109
+ SELECT gc.scopes, gc.metadata
110
+ INTO v_previous_scopes, v_metadata
111
+ FROM semicolony.gateway_credentials AS gc
112
+ WHERE gc.id = p_credential_id
113
+ AND gc.status = 'active'
114
+ AND gc.revoked_at IS NULL
115
+ AND (gc.expires_at IS NULL OR gc.expires_at > clock_timestamp())
116
+ AND gc.metadata ->> 'credential_kind' = 'worker'
117
+ FOR UPDATE;
118
+ IF NOT FOUND THEN
119
+ RAISE EXCEPTION 'active worker credential not found' USING ERRCODE = 'P0002';
120
+ END IF;
121
+
122
+ -- Workspace scopes stay bound to real grants, exactly as at issuance.
123
+ SELECT
124
+ bool_or(wpg.capability = 'credentials-read'),
125
+ bool_or(wpg.capability = 'assets-read')
126
+ INTO v_has_credential_grant, v_has_asset_grant
127
+ FROM semicolony.workspace_projection_grants AS wpg
128
+ WHERE wpg.gateway_credential_id = p_credential_id;
129
+
130
+ IF coalesce(v_has_credential_grant, false) <> ('workspace:credentials-read' = ANY(p_scopes))
131
+ OR coalesce(v_has_asset_grant, false) <> ('workspace:assets-read' = ANY(p_scopes)) THEN
132
+ RAISE EXCEPTION 'workspace scopes must exactly match workspace grants' USING ERRCODE = '22023';
133
+ END IF;
134
+
135
+ INSERT INTO semicolony.gateway_credential_scope_amendments (
136
+ credential_id, previous_scopes, next_scopes, reason, amended_by
137
+ ) VALUES (
138
+ p_credential_id, v_previous_scopes, p_scopes, btrim(p_reason), btrim(p_amended_by)
139
+ )
140
+ RETURNING amendment_id INTO v_amendment_id;
141
+
142
+ UPDATE semicolony.gateway_credentials
143
+ SET scopes = p_scopes
144
+ WHERE id = p_credential_id;
145
+
146
+ RETURN v_amendment_id;
147
+ END
148
+ $function$;
149
+
150
+ REVOKE ALL ON FUNCTION semicolony.amend_worker_gateway_credential_scopes(uuid, text[], text, text)
151
+ FROM PUBLIC, sc_app, sc_provider;
152
+ GRANT EXECUTE ON FUNCTION semicolony.amend_worker_gateway_credential_scopes(uuid, text[], text, text)
153
+ TO sc_app;
154
+
155
+ REVOKE ALL ON TABLE semicolony.gateway_credential_scope_amendments
156
+ FROM PUBLIC, sc_app, sc_provider;
157
+ GRANT SELECT ON TABLE semicolony.gateway_credential_scope_amendments TO sc_app;
@@ -0,0 +1,105 @@
1
+ -- 191_activation_allows_lease_free_stage.sql
2
+ --
3
+ -- `validate_skill_release_activation` used `created_under_lease_id IS NULL` as
4
+ -- its "the staged release does not exist" test, because before migration 189
5
+ -- that column was NOT NULL and a missing row was the only way to see NULL from
6
+ -- `SELECT ... INTO`. Making staging lease-free left every lease-free release
7
+ -- permanently unactivatable: it stages fine, then activation raises
8
+ -- `activation_release_identity_mismatch` on a release that plainly exists.
9
+ --
10
+ -- Separate the two questions the old test conflated:
11
+ -- * does the staged release exist -> NOT FOUND
12
+ -- * is the activation lease distinct -> only when a stage lease exists
13
+ --
14
+ -- The distinct-lease rule still holds wherever a stage lease was recorded, so
15
+ -- pre-189 releases keep exactly the protection they had. Every other check —
16
+ -- audience equality, sealed tree, previous-release ordering — is carried over
17
+ -- unchanged.
18
+ --
19
+ -- The runner owns the transaction boundary, so no BEGIN/COMMIT here, and it
20
+ -- retargets the `semicolony.` qualifier to the active schema.
21
+
22
+ CREATE OR REPLACE FUNCTION semicolony.validate_skill_release_activation()
23
+ RETURNS trigger
24
+ LANGUAGE plpgsql
25
+ AS $function$
26
+
27
+ DECLARE
28
+ staged_lease_id UUID;
29
+ staged_audiences TEXT[];
30
+ staged_file_count INTEGER;
31
+ staged_total_bytes BIGINT;
32
+ actual_file_count BIGINT;
33
+ actual_total_bytes BIGINT;
34
+ has_skill_md BOOLEAN;
35
+ current_release_id UUID;
36
+ BEGIN
37
+ PERFORM pg_advisory_xact_lock(
38
+ hashtextextended(NEW.channel || ':' || NEW.skill_name, 0)
39
+ );
40
+
41
+ SELECT
42
+ release.created_under_lease_id,
43
+ release.audiences,
44
+ release.file_count,
45
+ release.total_bytes
46
+ INTO
47
+ staged_lease_id,
48
+ staged_audiences,
49
+ staged_file_count,
50
+ staged_total_bytes
51
+ FROM semicolony.skill_releases AS release
52
+ WHERE release.release_id = NEW.release_id
53
+ AND release.channel = NEW.channel
54
+ AND release.skill_name = NEW.skill_name;
55
+
56
+ IF NOT FOUND THEN
57
+ RAISE EXCEPTION 'activation_release_identity_mismatch'
58
+ USING ERRCODE = '23503';
59
+ END IF;
60
+
61
+ IF staged_lease_id IS NOT NULL AND NEW.lease_id = staged_lease_id THEN
62
+ RAISE EXCEPTION 'activation_lease_must_differ_from_stage_lease'
63
+ USING ERRCODE = '23514';
64
+ END IF;
65
+
66
+ IF NEW.audiences IS DISTINCT FROM staged_audiences THEN
67
+ RAISE EXCEPTION 'activation_audiences_must_match_release'
68
+ USING ERRCODE = '23514';
69
+ END IF;
70
+
71
+ SELECT
72
+ COUNT(*),
73
+ COALESCE(SUM(file.size), 0),
74
+ COALESCE(BOOL_OR(file.relative_path = 'SKILL.md'), false)
75
+ INTO
76
+ actual_file_count,
77
+ actual_total_bytes,
78
+ has_skill_md
79
+ FROM semicolony.skill_release_files AS file
80
+ WHERE file.release_id = NEW.release_id;
81
+
82
+ IF actual_file_count <> staged_file_count
83
+ OR actual_total_bytes <> staged_total_bytes
84
+ OR NOT has_skill_md
85
+ THEN
86
+ RAISE EXCEPTION 'activation_release_tree_is_not_sealed'
87
+ USING ERRCODE = '23514';
88
+ END IF;
89
+
90
+ SELECT activation.release_id
91
+ INTO current_release_id
92
+ FROM semicolony.skill_release_activations AS activation
93
+ WHERE activation.channel = NEW.channel
94
+ AND activation.skill_name = NEW.skill_name
95
+ ORDER BY activation.activated_at DESC, activation.activation_id DESC
96
+ LIMIT 1;
97
+
98
+ IF NEW.previous_release_id IS DISTINCT FROM current_release_id THEN
99
+ RAISE EXCEPTION 'activation_previous_release_must_match_current'
100
+ USING ERRCODE = '23514';
101
+ END IF;
102
+
103
+ RETURN NEW;
104
+ END;
105
+ $function$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semicolony-cli",
3
- "version": "4.18.89",
3
+ "version": "4.18.91",
4
4
  "description": "SemiColony CLI - AI operations and agent orchestration installer",
5
5
  "main": "dist/bundle.js",
6
6
  "bin": {