@timidan/rite 0.1.0

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,252 @@
1
+ /**
2
+ * src/watsonx/draft.js — watsonx.ai case and path drafting.
3
+ *
4
+ * Sends a rule + limited code snippet to IBM watsonx.ai (via the chat API)
5
+ * and returns a candidate config diff for developer review.
6
+ *
7
+ * Authentication: IBM Cloud IAM API key → bearer token exchange.
8
+ * The token is never written to disk or logged.
9
+ *
10
+ * Environment variables (never passed to verify; server-side only):
11
+ * IBMCLOUD_API_KEY — IBM Cloud IAM API key
12
+ * WATSONX_PROJECT_ID — watsonx.ai project ID
13
+ * WATSONX_REGION — e.g. us-south (default)
14
+ * WATSONX_MODEL_ID — e.g. ibm/granite-3-3-8b-instruct (checked at runtime)
15
+ *
16
+ * If any credential is absent, draftCases() returns { available: false, reason }.
17
+ * The caller must display this state and never show a mocked response as live.
18
+ *
19
+ * API reference: https://cloud.ibm.com/docs/apis/watsonx-ai
20
+ */
21
+
22
+ const IAM_TOKEN_URL = 'https://iam.cloud.ibm.com/identity/token';
23
+ const DEFAULT_REGION = 'us-south';
24
+
25
+ // ------------------------------------------------------------------ //
26
+ // IAM token exchange //
27
+ // ------------------------------------------------------------------ //
28
+
29
+ /**
30
+ * Exchange an IBM Cloud API key for a short-lived bearer token.
31
+ * @param {string} apiKey
32
+ * @returns {Promise<string>} access token
33
+ */
34
+ async function getIamToken(apiKey) {
35
+ const body = new URLSearchParams({
36
+ grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
37
+ apikey: apiKey,
38
+ });
39
+
40
+ const resp = await fetch(IAM_TOKEN_URL, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
43
+ body: body.toString(),
44
+ });
45
+
46
+ if (!resp.ok) {
47
+ const text = await resp.text().catch(() => '');
48
+ throw new Error(`IAM token exchange failed (${resp.status}): ${text.slice(0, 200)}`);
49
+ }
50
+
51
+ const data = await resp.json();
52
+ if (!data.access_token) throw new Error('IAM response missing access_token');
53
+ return data.access_token;
54
+ }
55
+
56
+ // ------------------------------------------------------------------ //
57
+ // Model listing — verify entitlement at runtime //
58
+ // ------------------------------------------------------------------ //
59
+
60
+ /**
61
+ * List available foundation models in the project.
62
+ * Returns the first Granite text model found, or null.
63
+ * @param {string} token
64
+ * @param {string} projectId
65
+ * @param {string} region
66
+ * @returns {Promise<string|null>}
67
+ */
68
+ async function resolveModelId(token, projectId, region) {
69
+ const url = `https://${region}.ml.cloud.ibm.com/ml/v1/foundation_model_specs?version=2024-09-16&limit=50`;
70
+ try {
71
+ const resp = await fetch(url, {
72
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
73
+ });
74
+ if (!resp.ok) return null;
75
+ const data = await resp.json();
76
+ const models = data.resources ?? [];
77
+ // Prefer a granite instruct model
78
+ const granite = models.find(m =>
79
+ m.model_id?.includes('granite') && m.model_id?.includes('instruct')
80
+ );
81
+ return granite?.model_id ?? models[0]?.model_id ?? null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // ------------------------------------------------------------------ //
88
+ // Prompt construction //
89
+ // ------------------------------------------------------------------ //
90
+
91
+ /**
92
+ * Build the prompt asking watsonx for candidate config entries.
93
+ * Sends ONLY the rule and explicitly selected code snippet (size-limited).
94
+ * @param {string} rule
95
+ * @param {string} snippet — public/synthetic only, reviewed before sending
96
+ * @returns {string}
97
+ */
98
+ function buildPrompt(rule, snippet) {
99
+ return `You are helping a developer configure an authorization verification tool called Rite.
100
+
101
+ The developer has written this authorization rule:
102
+ "${rule}"
103
+
104
+ Here is a limited code snippet from their service (synthetic/public data only):
105
+ \`\`\`js
106
+ ${snippet.slice(0, 2000)}
107
+ \`\`\`
108
+
109
+ Please identify:
110
+ 1. Candidate entry point function names that could reach a sensitive operation.
111
+ 2. The likely sink function name (the sensitive operation).
112
+ 3. Three to five test cases: at least one that should be ALLOWED and at least two that should be DENIED (with different denial reasons).
113
+ 4. For each case: a unique ID, which entry path, a test actor ID, a minimal input object, expected decision (allow/deny), and a brief reason.
114
+
115
+ IMPORTANT:
116
+ - Only cite functions that appear in the snippet above. Label anything inferred as a hypothesis.
117
+ - Do not invent actors, amounts, or resource IDs beyond what the snippet implies.
118
+ - Return ONLY valid JSON matching this schema (no prose, no markdown fences):
119
+
120
+ {
121
+ "hypothesis": true,
122
+ "paths": [
123
+ { "entry": "functionName", "sink": "sinkName", "source": "filename.js" }
124
+ ],
125
+ "cases": [
126
+ {
127
+ "id": "case-id",
128
+ "path": "entryFunctionName",
129
+ "actor": "actor-id",
130
+ "input": { "resourceId": "resource-1" },
131
+ "expected": { "decision": "allow|deny", "effects": [], "stateChanged": false },
132
+ "reason": "One sentence."
133
+ }
134
+ ]
135
+ }`;
136
+ }
137
+
138
+ // ------------------------------------------------------------------ //
139
+ // Main draft function //
140
+ // ------------------------------------------------------------------ //
141
+
142
+ /**
143
+ * @typedef {{
144
+ * available: true,
145
+ * modelId: string,
146
+ * hypothesis: true,
147
+ * paths: object[],
148
+ * cases: object[],
149
+ * rawResponse: string,
150
+ * } | {
151
+ * available: false,
152
+ * reason: string,
153
+ * }} DraftResult
154
+ */
155
+
156
+ /**
157
+ * Call watsonx.ai to draft candidate paths and cases from a rule + snippet.
158
+ * Returns available:false when credentials are missing or the call fails.
159
+ * Never throws — errors are returned as available:false with a reason.
160
+ *
161
+ * @param {{ rule: string, snippet: string }} opts
162
+ * @returns {Promise<DraftResult>}
163
+ */
164
+ export async function draftCases({ rule, snippet }) {
165
+ const apiKey = process.env.IBMCLOUD_API_KEY;
166
+ const projectId = process.env.WATSONX_PROJECT_ID;
167
+ const region = process.env.WATSONX_REGION ?? DEFAULT_REGION;
168
+ const modelIdEnv = process.env.WATSONX_MODEL_ID;
169
+
170
+ if (!apiKey) return { available: false, reason: 'IBMCLOUD_API_KEY not set' };
171
+ if (!projectId) return { available: false, reason: 'WATSONX_PROJECT_ID not set' };
172
+
173
+ let token;
174
+ try {
175
+ token = await getIamToken(apiKey);
176
+ } catch (e) {
177
+ return { available: false, reason: `IAM auth failed: ${e.message}` };
178
+ }
179
+
180
+ // Resolve model at runtime — do not guess a model ID
181
+ const modelId = modelIdEnv ?? await resolveModelId(token, projectId, region);
182
+ if (!modelId) {
183
+ return { available: false, reason: 'No foundation model available in this project. Set WATSONX_MODEL_ID or check project entitlements.' };
184
+ }
185
+
186
+ const prompt = buildPrompt(rule, snippet);
187
+ const url = `https://${region}.ml.cloud.ibm.com/ml/v1/text/chat?version=2024-09-16`;
188
+
189
+ let rawResponse;
190
+ try {
191
+ const resp = await fetch(url, {
192
+ method: 'POST',
193
+ headers: {
194
+ Authorization: `Bearer ${token}`,
195
+ 'Content-Type': 'application/json',
196
+ },
197
+ body: JSON.stringify({
198
+ model_id: modelId,
199
+ project_id: projectId,
200
+ messages: [{ role: 'user', content: prompt }],
201
+ parameters: {
202
+ max_new_tokens: 1200,
203
+ temperature: 0.2,
204
+ },
205
+ }),
206
+ });
207
+
208
+ if (!resp.ok) {
209
+ const text = await resp.text().catch(() => '');
210
+ return { available: false, reason: `watsonx API error (${resp.status}): ${text.slice(0, 300)}` };
211
+ }
212
+
213
+ const data = await resp.json();
214
+ rawResponse = data?.results?.[0]?.generated_text
215
+ ?? data?.choices?.[0]?.message?.content
216
+ ?? JSON.stringify(data);
217
+ } catch (e) {
218
+ return { available: false, reason: `Network error calling watsonx: ${e.message}` };
219
+ }
220
+
221
+ // Parse response — treat as untrusted
222
+ let parsed;
223
+ try {
224
+ // Strip markdown fences if present
225
+ const cleaned = rawResponse.replace(/^```(?:json)?\s*/m, '').replace(/\s*```\s*$/m, '').trim();
226
+ parsed = JSON.parse(cleaned);
227
+ } catch {
228
+ return { available: false, reason: `watsonx returned non-JSON response. Raw: ${rawResponse.slice(0, 300)}` };
229
+ }
230
+
231
+ // Validate required fields
232
+ if (!Array.isArray(parsed.paths) || !Array.isArray(parsed.cases)) {
233
+ return { available: false, reason: 'watsonx response missing paths or cases arrays. Check prompt and model.' };
234
+ }
235
+
236
+ // Verify all path/case citations are in the snippet
237
+ for (const p of parsed.paths) {
238
+ if (typeof p.entry === 'string' && !snippet.includes(p.entry)) {
239
+ // Label as unverified hypothesis rather than reject
240
+ p.hypothesis = true;
241
+ }
242
+ }
243
+
244
+ return {
245
+ available: true,
246
+ modelId,
247
+ hypothesis: true, // Always label model output as hypothesis
248
+ paths: parsed.paths,
249
+ cases: parsed.cases,
250
+ rawResponse,
251
+ };
252
+ }
@@ -0,0 +1,43 @@
1
+ name: Rite Authorization Check
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+ branches: [main, master]
8
+
9
+ permissions:
10
+ contents: read
11
+ actions: read
12
+
13
+ jobs:
14
+ rite-verify:
15
+ name: Rite — verify authorization rule
16
+ runs-on: ubuntu-latest
17
+
18
+ steps:
19
+ - name: Checkout
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Set up Node.js
23
+ uses: actions/setup-node@v4
24
+ with:
25
+ node-version: "22"
26
+
27
+ - name: Run Rite verification
28
+ run: >-
29
+ npx --yes @timidan/rite@0.1.0 verify
30
+ --config rite.config.json
31
+ --out rite-report.json
32
+
33
+ - name: Upload Rite report
34
+ if: always() && hashFiles('rite-report.json') != ''
35
+ uses: actions/upload-artifact@v4
36
+ with:
37
+ name: rite-report
38
+ path: rite-report.json
39
+ retention-days: 90
40
+
41
+ - name: Write job summary
42
+ if: always() && hashFiles('rite-report.json') != ''
43
+ run: npx --yes @timidan/rite@0.1.0 report rite-report.json >> "$GITHUB_STEP_SUMMARY"