shield-harness 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.
Files changed (43) hide show
  1. package/.claude/hooks/lib/ocsf-mapper.js +279 -0
  2. package/.claude/hooks/lib/openshell-detect.js +235 -0
  3. package/.claude/hooks/lib/policy-compat.js +176 -0
  4. package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
  5. package/.claude/hooks/lib/sh-utils.js +340 -0
  6. package/.claude/hooks/lint-on-save.js +240 -0
  7. package/.claude/hooks/sh-circuit-breaker.js +113 -0
  8. package/.claude/hooks/sh-config-guard.js +275 -0
  9. package/.claude/hooks/sh-data-boundary.js +390 -0
  10. package/.claude/hooks/sh-dep-audit.js +101 -0
  11. package/.claude/hooks/sh-elicitation.js +244 -0
  12. package/.claude/hooks/sh-evidence.js +193 -0
  13. package/.claude/hooks/sh-gate.js +365 -0
  14. package/.claude/hooks/sh-injection-guard.js +196 -0
  15. package/.claude/hooks/sh-instructions.js +212 -0
  16. package/.claude/hooks/sh-output-control.js +217 -0
  17. package/.claude/hooks/sh-permission-learn.js +227 -0
  18. package/.claude/hooks/sh-permission.js +157 -0
  19. package/.claude/hooks/sh-pipeline.js +623 -0
  20. package/.claude/hooks/sh-postcompact.js +173 -0
  21. package/.claude/hooks/sh-precompact.js +114 -0
  22. package/.claude/hooks/sh-quiet-inject.js +148 -0
  23. package/.claude/hooks/sh-session-end.js +143 -0
  24. package/.claude/hooks/sh-session-start.js +277 -0
  25. package/.claude/hooks/sh-subagent.js +86 -0
  26. package/.claude/hooks/sh-task-gate.js +141 -0
  27. package/.claude/hooks/sh-user-prompt.js +185 -0
  28. package/.claude/hooks/sh-worktree.js +230 -0
  29. package/.claude/patterns/injection-patterns.json +137 -0
  30. package/.claude/policies/openshell-default.yaml +65 -0
  31. package/.claude/rules/binding-governance.md +62 -0
  32. package/.claude/rules/channel-security.md +90 -0
  33. package/.claude/rules/coding-principles.md +79 -0
  34. package/.claude/rules/dev-environment.md +40 -0
  35. package/.claude/rules/implementation-context.md +132 -0
  36. package/.claude/rules/language.md +26 -0
  37. package/.claude/rules/security.md +109 -0
  38. package/.claude/rules/testing.md +43 -0
  39. package/LICENSE +21 -0
  40. package/README.ja.md +176 -0
  41. package/README.md +174 -0
  42. package/bin/shield-harness.js +241 -0
  43. package/package.json +42 -0
@@ -0,0 +1,623 @@
1
+ #!/usr/bin/env node
2
+ // sh-pipeline.js — STG gate-driven pipeline (Node.js port)
3
+ // Spec: DETAILED_DESIGN.md §8.1
4
+ // Event: TaskCompleted
5
+ // Execution order: after sh-task-gate.js
6
+ // Target response time: < 30000ms
7
+ "use strict";
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const { execSync } = require("child_process");
12
+ const {
13
+ readHookInput,
14
+ allow,
15
+ deny,
16
+ readSession,
17
+ writeSession,
18
+ readYaml,
19
+ appendEvidence,
20
+ commandExists,
21
+ SH_DIR,
22
+ } = require("./lib/sh-utils");
23
+
24
+ const HOOK_NAME = "sh-pipeline";
25
+ const PIPELINE_CONFIG = path.join(SH_DIR, "config", "pipeline-config.json");
26
+ const BACKLOG_FILE = path.join("tasks", "backlog.yaml");
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Load pipeline configuration.
34
+ * @returns {Object|null}
35
+ */
36
+ function loadPipelineConfig() {
37
+ try {
38
+ if (!fs.existsSync(PIPELINE_CONFIG)) return null;
39
+ return JSON.parse(fs.readFileSync(PIPELINE_CONFIG, "utf8"));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get task data from backlog.yaml.
47
+ * @param {string} taskId
48
+ * @returns {{ stage_status: string, intent: string, branch: string, pr_url: string }|null}
49
+ */
50
+ function getTaskData(taskId) {
51
+ try {
52
+ const backlog = readYaml(BACKLOG_FILE);
53
+ const tasks = backlog.tasks || [];
54
+ const task = tasks.find((t) => t.id === taskId);
55
+ if (!task) return null;
56
+ return {
57
+ stage_status: task.stage_status || null,
58
+ intent: task.intent || "",
59
+ branch: task.branch || "",
60
+ pr_url: task.pr_url || "",
61
+ };
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Execute a trusted git operation in a child process.
69
+ * Uses SH_PIPELINE=1 env to identify trusted operations.
70
+ * @param {string} taskId
71
+ * @param {string} command
72
+ * @returns {string} stdout
73
+ */
74
+ function executeTrusted(taskId, command) {
75
+ return execSync(command, {
76
+ encoding: "utf8",
77
+ timeout: 30000,
78
+ env: {
79
+ ...process.env,
80
+ SH_PIPELINE: "1",
81
+ SH_TASK_ID: taskId,
82
+ },
83
+ stdio: ["pipe", "pipe", "pipe"],
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Update backlog.yaml task fields via js-yaml.
89
+ * @param {string} taskId
90
+ * @param {Object} updates - key-value pairs to update
91
+ */
92
+ function updateBacklog(taskId, updates) {
93
+ let yaml;
94
+ try {
95
+ yaml = require("js-yaml");
96
+ } catch {
97
+ // js-yaml not available — skip backlog update
98
+ return;
99
+ }
100
+
101
+ try {
102
+ const content = fs.readFileSync(BACKLOG_FILE, "utf8");
103
+ const backlog = yaml.load(content);
104
+ const tasks = backlog.tasks || [];
105
+ const task = tasks.find((t) => t.id === taskId);
106
+ if (!task) return;
107
+
108
+ // Apply updates
109
+ for (const [key, value] of Object.entries(updates)) {
110
+ if (key === "stg_history_push") {
111
+ if (!Array.isArray(task.stg_history)) task.stg_history = [];
112
+ task.stg_history.push(value);
113
+ } else {
114
+ task[key] = value;
115
+ }
116
+ }
117
+
118
+ // Write back
119
+ const output = yaml.dump(backlog, {
120
+ lineWidth: -1,
121
+ noRefs: true,
122
+ quotingType: '"',
123
+ forceQuotes: false,
124
+ });
125
+ fs.writeFileSync(BACKLOG_FILE, output);
126
+ } catch {
127
+ // Backlog update failure is non-critical for pipeline
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Format commit message from template.
133
+ * @param {string} template
134
+ * @param {string} taskId
135
+ * @param {string} gate
136
+ * @param {string} intent
137
+ * @returns {string}
138
+ */
139
+ function formatCommitMsg(template, taskId, gate, intent) {
140
+ return template
141
+ .replace("{task_id}", taskId)
142
+ .replace("{gate}", gate)
143
+ .replace("{intent}", intent);
144
+ }
145
+
146
+ // Priority weight for sorting (lower = higher priority)
147
+ const PRIORITY_WEIGHT = { must: 0, should: 1, could: 2 };
148
+
149
+ // Maximum auto-pickups per session (infinite loop guard)
150
+ const MAX_AUTO_PICKUPS = 10;
151
+
152
+ /**
153
+ * Find the next eligible task from backlog.yaml.
154
+ * Filters: status === "backlog", all depends_on are "done".
155
+ * Sorts: priority (must > should > could), then due_date ascending.
156
+ * @returns {{ id: string, intent: string, priority: string }|null}
157
+ */
158
+ function findNextTask() {
159
+ try {
160
+ const backlog = readYaml(BACKLOG_FILE);
161
+ const tasks = backlog.tasks || [];
162
+
163
+ // Build status lookup for dependency checking
164
+ const statusMap = {};
165
+ for (const t of tasks) {
166
+ statusMap[t.id] = t.status;
167
+ }
168
+
169
+ // Filter candidates: backlog status + all deps done
170
+ const candidates = tasks.filter((t) => {
171
+ if (t.status !== "backlog") return false;
172
+ const deps = t.depends_on || [];
173
+ return deps.every((depId) => statusMap[depId] === "done");
174
+ });
175
+
176
+ if (candidates.length === 0) return null;
177
+
178
+ // Sort: priority ascending, then due_date ascending (null last)
179
+ candidates.sort((a, b) => {
180
+ const pa = PRIORITY_WEIGHT[a.priority] ?? 99;
181
+ const pb = PRIORITY_WEIGHT[b.priority] ?? 99;
182
+ if (pa !== pb) return pa - pb;
183
+
184
+ // due_date: null → Infinity for sorting
185
+ const da = a.due_date ? new Date(a.due_date).getTime() : Infinity;
186
+ const db = b.due_date ? new Date(b.due_date).getTime() : Infinity;
187
+ return da - db;
188
+ });
189
+
190
+ const next = candidates[0];
191
+ return { id: next.id, intent: next.intent, priority: next.priority };
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Bump version in package.json and return the new version string.
199
+ * @param {string} bumpType - "patch" | "minor" | "major"
200
+ * @returns {string|null} New version string, or null if package.json not found.
201
+ */
202
+ function bumpVersion(bumpType) {
203
+ const pkgPath = "package.json";
204
+ if (!fs.existsSync(pkgPath)) return null;
205
+
206
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
207
+ const parts = (pkg.version || "0.0.0").split(".").map(Number);
208
+
209
+ switch (bumpType) {
210
+ case "major":
211
+ parts[0] += 1;
212
+ parts[1] = 0;
213
+ parts[2] = 0;
214
+ break;
215
+ case "minor":
216
+ parts[1] += 1;
217
+ parts[2] = 0;
218
+ break;
219
+ case "patch":
220
+ default:
221
+ parts[2] += 1;
222
+ break;
223
+ }
224
+
225
+ const newVersion = parts.join(".");
226
+ pkg.version = newVersion;
227
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
228
+ return newVersion;
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Main
233
+ // ---------------------------------------------------------------------------
234
+
235
+ try {
236
+ const input = readHookInput();
237
+
238
+ // Step 0: Load pipeline config
239
+ const config = loadPipelineConfig();
240
+ if (!config || config.auto_commit !== true) {
241
+ allow();
242
+ return;
243
+ }
244
+
245
+ const autoCommit = config.auto_commit === true;
246
+ const autoPush = config.auto_push === true;
247
+ const autoPR = config.auto_pr === true;
248
+ const autoMerge = config.auto_merge === true;
249
+ const autoTag = config.auto_tag === true;
250
+ const versionBump = config.version_bump || "patch";
251
+ const commitFmt =
252
+ config.commit_message_format || "[{task_id}] STG{gate}: {intent}";
253
+
254
+ // Step 1: Get active task
255
+ const session = readSession();
256
+ const taskId = session.active_task_id;
257
+ if (!taskId) {
258
+ allow();
259
+ return;
260
+ }
261
+
262
+ // Step 2: Get stage status
263
+ const taskData = getTaskData(taskId);
264
+ if (!taskData) {
265
+ allow();
266
+ return;
267
+ }
268
+
269
+ const stageStatus = taskData.stage_status;
270
+ if (!stageStatus) {
271
+ allow();
272
+ return;
273
+ }
274
+
275
+ // Step 3: STG gate progression
276
+ let summary = "";
277
+ const timestamp = new Date().toISOString();
278
+ const today = timestamp.slice(0, 10);
279
+
280
+ switch (stageStatus) {
281
+ case null:
282
+ case "stg0_passed":
283
+ case "stg1_passed": {
284
+ // STG2: Auto commit
285
+ if (!autoCommit) break;
286
+
287
+ const commitMsg = formatCommitMsg(
288
+ commitFmt,
289
+ taskId,
290
+ "2",
291
+ taskData.intent,
292
+ );
293
+ const branchName = `feature/${taskId}`;
294
+
295
+ try {
296
+ // Ensure feature branch
297
+ try {
298
+ executeTrusted(taskId, `git checkout -b "${branchName}"`);
299
+ } catch {
300
+ try {
301
+ executeTrusted(taskId, `git checkout "${branchName}"`);
302
+ } catch {
303
+ // Already on the branch
304
+ }
305
+ }
306
+
307
+ // Sync project views + README drift check (ADR-033, ADR-035)
308
+ if (commandExists("pwsh")) {
309
+ try {
310
+ executeTrusted(taskId, "pwsh scripts/sync-project-views.ps1");
311
+ } catch {
312
+ // Non-critical
313
+ }
314
+ try {
315
+ executeTrusted(taskId, "pwsh scripts/sync-readme.ps1");
316
+ } catch {
317
+ // Non-critical — drift is reported but does not block
318
+ }
319
+ }
320
+
321
+ // Update backlog
322
+ updateBacklog(taskId, {
323
+ stage_status: "stg2_passed",
324
+ start_date: today,
325
+ branch: branchName,
326
+ stg_history_push: { gate: "stg2", passed_at: timestamp },
327
+ });
328
+
329
+ // Stage and commit
330
+ executeTrusted(taskId, "git add -A");
331
+ try {
332
+ executeTrusted(taskId, `git commit -m "${commitMsg}"`);
333
+ } catch {
334
+ // No changes to commit
335
+ }
336
+
337
+ summary = `STG2 passed: auto-committed [${taskId}]`;
338
+ } catch (err) {
339
+ summary = `STG2 error: ${err.message}`;
340
+ }
341
+ break;
342
+ }
343
+
344
+ case "stg2_passed": {
345
+ // STG3: Auto push
346
+ if (!autoPush) break;
347
+
348
+ const branchName = taskData.branch || `feature/${taskId}`;
349
+
350
+ try {
351
+ executeTrusted(taskId, `git push -u origin "${branchName}"`);
352
+
353
+ updateBacklog(taskId, {
354
+ stage_status: "stg3_passed",
355
+ stg_history_push: { gate: "stg3", passed_at: timestamp },
356
+ });
357
+
358
+ // Commit backlog update
359
+ executeTrusted(taskId, "git add tasks/backlog.yaml");
360
+ try {
361
+ executeTrusted(
362
+ taskId,
363
+ `git commit -m "[${taskId}] STG3: pushed to remote"`,
364
+ );
365
+ } catch {
366
+ // No changes
367
+ }
368
+
369
+ summary = `STG3 passed: pushed to ${branchName}`;
370
+ } catch (err) {
371
+ summary = `STG3 error: ${err.message}`;
372
+ }
373
+ break;
374
+ }
375
+
376
+ case "stg3_passed":
377
+ case "stg4_passed": {
378
+ // STG5: Auto PR
379
+ if (!autoPR) break;
380
+
381
+ if (!commandExists("gh")) {
382
+ summary = `gh CLI not found. Please create PR manually for feature/${taskId}`;
383
+ break;
384
+ }
385
+
386
+ try {
387
+ const prUrl = executeTrusted(
388
+ taskId,
389
+ `gh pr create --title "[${taskId}] ${taskData.intent}" --body "Auto-generated by shield-harness pipeline (ADR-031)"`,
390
+ ).trim();
391
+
392
+ if (prUrl) {
393
+ updateBacklog(taskId, {
394
+ stage_status: "stg5_passed",
395
+ pr_url: prUrl,
396
+ stg_history_push: { gate: "stg5", passed_at: timestamp },
397
+ });
398
+
399
+ executeTrusted(taskId, "git add tasks/backlog.yaml");
400
+ try {
401
+ executeTrusted(
402
+ taskId,
403
+ `git commit -m "[${taskId}] STG5: PR created"`,
404
+ );
405
+ } catch {
406
+ // No changes
407
+ }
408
+
409
+ summary = `STG5 passed: PR created at ${prUrl}`;
410
+ } else {
411
+ summary = `STG5: PR creation failed for feature/${taskId}`;
412
+ }
413
+ } catch (err) {
414
+ summary = `STG5 error: ${err.message}`;
415
+ }
416
+ break;
417
+ }
418
+
419
+ case "stg5_passed": {
420
+ // STG6: Auto merge
421
+ if (!autoMerge) break;
422
+
423
+ if (!commandExists("gh")) {
424
+ summary = "gh CLI not found. Please merge PR manually.";
425
+ break;
426
+ }
427
+
428
+ try {
429
+ const branchName = taskData.branch || `feature/${taskId}`;
430
+ const prNumberStr = executeTrusted(
431
+ taskId,
432
+ `gh pr list --head "${branchName}" --json number -q ".[0].number"`,
433
+ ).trim();
434
+
435
+ if (!prNumberStr) {
436
+ summary = `STG5: No PR found for ${branchName}`;
437
+ break;
438
+ }
439
+
440
+ // Check CI status
441
+ let failedCount;
442
+ try {
443
+ failedCount = executeTrusted(
444
+ taskId,
445
+ `gh pr checks ${prNumberStr} --json state -q '[.[] | select(.state != "SUCCESS")] | length'`,
446
+ ).trim();
447
+ } catch {
448
+ failedCount = "unknown";
449
+ }
450
+
451
+ if (failedCount !== "0") {
452
+ summary = `STG5: CI checks not passed yet (${failedCount} failing). Waiting...`;
453
+ break;
454
+ }
455
+
456
+ // Merge
457
+ executeTrusted(taskId, `gh pr merge ${prNumberStr} --squash`);
458
+ executeTrusted(taskId, "git checkout main");
459
+ executeTrusted(taskId, "git pull origin main");
460
+
461
+ // Branch cleanup
462
+ try {
463
+ executeTrusted(taskId, `git branch -d "${branchName}"`);
464
+ } catch {
465
+ // Already deleted
466
+ }
467
+ try {
468
+ executeTrusted(taskId, `git push origin --delete "${branchName}"`);
469
+ } catch {
470
+ // Already deleted remotely
471
+ }
472
+
473
+ // Update backlog to done
474
+ updateBacklog(taskId, {
475
+ status: "done",
476
+ stage_status: "stg6_passed",
477
+ completed_date: today,
478
+ stg_history_push: { gate: "stg6", passed_at: timestamp },
479
+ });
480
+
481
+ // Sync views
482
+ if (commandExists("pwsh")) {
483
+ try {
484
+ executeTrusted(taskId, "pwsh scripts/sync-project-views.ps1");
485
+ } catch {
486
+ // Non-critical
487
+ }
488
+ }
489
+
490
+ // Final commit
491
+ executeTrusted(taskId, "git add -A");
492
+ try {
493
+ executeTrusted(
494
+ taskId,
495
+ `git commit -m "[${taskId}] STG6: merged and completed"`,
496
+ );
497
+ } catch {
498
+ // No changes
499
+ }
500
+
501
+ // Auto-tag release version (TASK-013)
502
+ if (autoTag) {
503
+ try {
504
+ const newVersion = bumpVersion(versionBump);
505
+ if (newVersion) {
506
+ const tag = `v${newVersion}`;
507
+ executeTrusted(taskId, `git tag "${tag}"`);
508
+ executeTrusted(taskId, `git push origin "${tag}"`);
509
+ summary = `STG6 passed: PR #${prNumberStr} merged, tagged ${tag} [${taskId}]`;
510
+ } else {
511
+ summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}] (tag skipped: no package.json)`;
512
+ }
513
+ } catch (tagErr) {
514
+ summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}] (tag failed: ${tagErr.message})`;
515
+ }
516
+ } else {
517
+ summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}]`;
518
+ }
519
+ } catch (err) {
520
+ summary = `STG6 error: ${err.message}`;
521
+ }
522
+ break;
523
+ }
524
+
525
+ case "stg6_passed": {
526
+ // Auto-pickup next task (ADR-034)
527
+ if (!config.auto_pickup_next_task) {
528
+ summary = `Task ${taskId} already completed (stg6_passed)`;
529
+ break;
530
+ }
531
+
532
+ // Infinite loop guard
533
+ const session = readSession();
534
+ const pickupCount = session.auto_pickup_count || 0;
535
+ if (pickupCount >= MAX_AUTO_PICKUPS) {
536
+ summary = `Auto-pickup limit reached (${MAX_AUTO_PICKUPS}). Stopping.`;
537
+ break;
538
+ }
539
+
540
+ const nextTask = findNextTask();
541
+ if (!nextTask) {
542
+ summary = "All tasks completed or no eligible tasks found.";
543
+ break;
544
+ }
545
+
546
+ try {
547
+ // Update next task to in_progress
548
+ const nextBranch = `feature/${nextTask.id}`;
549
+ updateBacklog(nextTask.id, {
550
+ status: "in_progress",
551
+ stage_status: "stg0_passed",
552
+ start_date: today,
553
+ branch: nextBranch,
554
+ stg_history_push: { gate: "stg0", passed_at: timestamp },
555
+ });
556
+
557
+ // Create branch
558
+ try {
559
+ executeTrusted(nextTask.id, `git checkout -b "${nextBranch}"`);
560
+ } catch {
561
+ try {
562
+ executeTrusted(nextTask.id, `git checkout "${nextBranch}"`);
563
+ } catch {
564
+ // Already on branch
565
+ }
566
+ }
567
+
568
+ // Update session
569
+ writeSession({
570
+ ...session,
571
+ active_task_id: nextTask.id,
572
+ auto_pickup_count: pickupCount + 1,
573
+ });
574
+
575
+ summary = `Auto-pickup: starting ${nextTask.id} (${nextTask.intent}) [${nextTask.priority}]`;
576
+ } catch (err) {
577
+ summary = `Auto-pickup error: ${err.message}`;
578
+ }
579
+ break;
580
+ }
581
+
582
+ default:
583
+ summary = `Unknown stage: ${stageStatus}`;
584
+ break;
585
+ }
586
+
587
+ // Step 4: Output
588
+ if (summary) {
589
+ try {
590
+ appendEvidence({
591
+ hook: HOOK_NAME,
592
+ event: "TaskCompleted",
593
+ decision: "allow",
594
+ task_id: taskId,
595
+ stage: stageStatus,
596
+ summary,
597
+ session_id: input.sessionId,
598
+ });
599
+ } catch {
600
+ // Non-blocking
601
+ }
602
+
603
+ allow(`[${HOOK_NAME}] ${summary}`);
604
+ return;
605
+ }
606
+
607
+ allow();
608
+ } catch (_err) {
609
+ // Pipeline is operational — fail-open
610
+ allow();
611
+ }
612
+
613
+ // ---------------------------------------------------------------------------
614
+ // Exports (for testing)
615
+ // ---------------------------------------------------------------------------
616
+
617
+ module.exports = {
618
+ loadPipelineConfig,
619
+ getTaskData,
620
+ executeTrusted,
621
+ updateBacklog,
622
+ formatCommitMsg,
623
+ };