@harness-engineering/cli 1.24.3 → 1.25.1

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 (116) hide show
  1. package/dist/agents/skills/claude-code/harness-architecture-advisor/SKILL.md +10 -6
  2. package/dist/agents/skills/claude-code/harness-code-review/SKILL.md +40 -5
  3. package/dist/agents/skills/claude-code/harness-git-workflow/SKILL.md +32 -11
  4. package/dist/agents/skills/claude-code/harness-planning/SKILL.md +19 -0
  5. package/dist/agents/skills/claude-code/harness-skill-authoring/SKILL.md +42 -7
  6. package/dist/agents/skills/claude-code/harness-verification/SKILL.md +25 -0
  7. package/dist/agents/skills/codex/harness-architecture-advisor/SKILL.md +10 -6
  8. package/dist/agents/skills/codex/harness-code-review/SKILL.md +40 -5
  9. package/dist/agents/skills/codex/harness-git-workflow/SKILL.md +32 -11
  10. package/dist/agents/skills/codex/harness-planning/SKILL.md +19 -0
  11. package/dist/agents/skills/codex/harness-skill-authoring/SKILL.md +42 -7
  12. package/dist/agents/skills/codex/harness-verification/SKILL.md +25 -0
  13. package/dist/agents/skills/cursor/harness-architecture-advisor/SKILL.md +10 -6
  14. package/dist/agents/skills/cursor/harness-code-review/SKILL.md +40 -5
  15. package/dist/agents/skills/cursor/harness-git-workflow/SKILL.md +32 -11
  16. package/dist/agents/skills/cursor/harness-planning/SKILL.md +19 -0
  17. package/dist/agents/skills/cursor/harness-skill-authoring/SKILL.md +42 -7
  18. package/dist/agents/skills/cursor/harness-verification/SKILL.md +25 -0
  19. package/dist/agents/skills/gemini-cli/harness-architecture-advisor/SKILL.md +10 -6
  20. package/dist/agents/skills/gemini-cli/harness-code-review/SKILL.md +40 -5
  21. package/dist/agents/skills/gemini-cli/harness-git-workflow/SKILL.md +32 -11
  22. package/dist/agents/skills/gemini-cli/harness-planning/SKILL.md +19 -0
  23. package/dist/agents/skills/gemini-cli/harness-skill-authoring/SKILL.md +42 -7
  24. package/dist/agents/skills/gemini-cli/harness-verification/SKILL.md +25 -0
  25. package/dist/agents-md-MCUM4SIZ.js +10 -0
  26. package/dist/{architecture-FBSLURIB.js → architecture-HNIO6AUX.js} +6 -5
  27. package/dist/{assess-project-74UVWPMB.js → assess-project-6MV5TNY3.js} +2 -1
  28. package/dist/bin/harness-mcp.js +18 -17
  29. package/dist/bin/harness.js +47 -31
  30. package/dist/{check-phase-gate-WY6UICCL.js → check-phase-gate-VCBQHQAC.js} +6 -5
  31. package/dist/{chunk-Q3XYV5UC.js → chunk-47N6R2F4.js} +5 -1
  32. package/dist/{chunk-3XAPHB2Z.js → chunk-4NK7Z3BP.js} +1 -1
  33. package/dist/{chunk-I4QR3HNH.js → chunk-5BQ5BOJL.js} +5 -1
  34. package/dist/{chunk-LM5Z2WCA.js → chunk-6VZQJ5CX.js} +5 -1
  35. package/dist/{chunk-NV4FPO7J.js → chunk-AT74HEQM.js} +1 -1
  36. package/dist/{chunk-TDLOFNNQ.js → chunk-BOQRTARD.js} +1 -1
  37. package/dist/{chunk-ZAMT24QN.js → chunk-CJXVNDZZ.js} +1537 -737
  38. package/dist/{chunk-A737JDL4.js → chunk-CXJTVICF.js} +4 -4
  39. package/dist/{chunk-LOUH2LIC.js → chunk-EHRZMOQ2.js} +5 -1
  40. package/dist/{chunk-L5UONZ53.js → chunk-F23H3U5U.js} +6 -2
  41. package/dist/{chunk-YPKKEP3O.js → chunk-FES2YEQU.js} +9 -9
  42. package/dist/{chunk-SPTKLCKC.js → chunk-FIQL2HND.js} +6 -2
  43. package/dist/{chunk-FP53DDB5.js → chunk-FSNPBT74.js} +5 -1
  44. package/dist/{chunk-TB427QOK.js → chunk-GEEYCQDS.js} +13 -9
  45. package/dist/{chunk-ZOVATVQC.js → chunk-HIK6OEUF.js} +6 -1
  46. package/dist/chunk-K2SON7TI.js +5382 -0
  47. package/dist/chunk-KFQGP6VL.js +33 -0
  48. package/dist/{chunk-V2FGX2KD.js → chunk-M55DGGF3.js} +9 -5
  49. package/dist/{chunk-H4U2QNY2.js → chunk-MI6MA6OP.js} +16888 -13143
  50. package/dist/{chunk-YDRB55Q4.js → chunk-P7PANON5.js} +5 -1
  51. package/dist/chunk-RL4VMEXL.js +53 -0
  52. package/dist/{chunk-XNEMDKV5.js → chunk-RRHUCDRD.js} +1 -1
  53. package/dist/{chunk-O6UF33QH.js → chunk-RX7TUMBR.js} +320 -136
  54. package/dist/{chunk-3VYWO6QN.js → chunk-TYV4EUAD.js} +12 -8
  55. package/dist/{chunk-FJYP32IV.js → chunk-UQZBZINS.js} +6 -6
  56. package/dist/{chunk-UFQQBGC3.js → chunk-UV3BZMGT.js} +64 -3
  57. package/dist/{chunk-5PMRARB5.js → chunk-WIQA4BSH.js} +12 -5
  58. package/dist/{chunk-UQEUYRBP.js → chunk-WWACIIBA.js} +1 -1
  59. package/dist/{chunk-LVYPPKZI.js → chunk-XTITAVUR.js} +5 -1
  60. package/dist/{chunk-WEN5Z7CL.js → chunk-YTP2UDPV.js} +3 -3
  61. package/dist/ci-workflow-RTM7VVTD.js +10 -0
  62. package/dist/{constants-5JGUXPEK.js → constants-P4M3C2T3.js} +1 -0
  63. package/dist/{create-skill-QCXINA5Q.js → create-skill-6QWJHQYS.js} +3 -2
  64. package/dist/{dist-666AAZQ6.js → dist-3EWNRFFQ.js} +6 -1
  65. package/dist/{dist-RRMRZRV7.js → dist-ABTKRYZH.js} +1 -0
  66. package/dist/{dist-QW5G3GX3.js → dist-RADHOOXG.js} +4 -1
  67. package/dist/{dist-7EBSGAHX.js → dist-WCSJHQPK.js} +106 -3
  68. package/dist/{docs-H34GBVRS.js → docs-UBOGGHTY.js} +6 -5
  69. package/dist/engine-MJJAP5CH.js +10 -0
  70. package/dist/{entropy-ZAY73R6A.js → entropy-EMSXF2PX.js} +5 -4
  71. package/dist/{feedback-TMEGYMWU.js → feedback-ZLUX72HD.js} +2 -1
  72. package/dist/{generate-agent-definitions-PQPG6SX5.js → generate-agent-definitions-AWLPJ27C.js} +6 -5
  73. package/dist/{glob-helper-SRXMZPKM.js → glob-helper-VCQXK5XY.js} +2 -0
  74. package/dist/{graph-loader-M6FXJAKK.js → graph-loader-JHQVQRUS.js} +2 -1
  75. package/dist/hooks/adoption-tracker.js +189 -0
  76. package/dist/hooks/block-no-verify.js +50 -0
  77. package/dist/hooks/cost-tracker.js +66 -0
  78. package/dist/hooks/pre-compact-state.js +115 -0
  79. package/dist/hooks/profiles.ts +48 -0
  80. package/dist/hooks/protect-config.js +75 -0
  81. package/dist/hooks/quality-gate.js +131 -0
  82. package/dist/hooks/sentinel-post.js +159 -0
  83. package/dist/hooks/sentinel-pre.js +244 -0
  84. package/dist/hooks/telemetry-reporter.js +248 -0
  85. package/dist/index.d.ts +13 -7
  86. package/dist/index.js +30 -29
  87. package/dist/loader-JVSJZSWZ.js +12 -0
  88. package/dist/mcp-2553PNUC.js +38 -0
  89. package/dist/{performance-N67YJJDG.js → performance-7AGWJUY4.js} +6 -5
  90. package/dist/review-pipeline-ZWVQJTJX.js +13 -0
  91. package/dist/{runner-TY7DJGQV.js → runner-AAEF2TXY.js} +1 -0
  92. package/dist/runtime-D6YUQPP2.js +11 -0
  93. package/dist/{scan-U67OKDRS.js → scan-MPJ6JHUY.js} +2 -1
  94. package/dist/security-JLZUAQYT.js +14 -0
  95. package/dist/skill-executor-MOCUIAYS.js +9 -0
  96. package/dist/templates/base/.agnix.toml +16 -0
  97. package/dist/templates/orchestrator/WORKFLOW.md +55 -7
  98. package/dist/templates/orchestrator/template.json +1 -0
  99. package/dist/tool-tiers-7QGZ3FKY.js +98 -0
  100. package/dist/validate-TIIHRPMA.js +14 -0
  101. package/dist/validate-cross-check-ZOWFA3DB.js +10 -0
  102. package/dist/{version-KFFPOQAX.js → version-CUI434OP.js} +1 -0
  103. package/package.json +4 -4
  104. package/dist/agents-md-PBKKTSQY.js +0 -9
  105. package/dist/chunk-5LMZA5LZ.js +0 -38
  106. package/dist/chunk-OZIHPMA7.js +0 -5171
  107. package/dist/ci-workflow-QZRHAIO2.js +0 -9
  108. package/dist/engine-VUQEAJFZ.js +0 -9
  109. package/dist/loader-Y6A42WBD.js +0 -11
  110. package/dist/mcp-LCHC4NZ5.js +0 -37
  111. package/dist/review-pipeline-YXF5ITL2.js +0 -12
  112. package/dist/runtime-XNJUJCSG.js +0 -10
  113. package/dist/security-L2YN3CTI.js +0 -9
  114. package/dist/skill-executor-GA7BDX3F.js +0 -8
  115. package/dist/validate-MNE25KLZ.js +0 -13
  116. package/dist/validate-cross-check-Y4PDR63C.js +0 -9
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /* global console */
3
+ // sentinel-post.js — PostToolUse:* hook
4
+ // Sentinel prompt injection defense — scans tool outputs for injection patterns.
5
+ // Exit codes: always 0 (PostToolUse cannot block)
6
+
7
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { resolve, dirname } from 'node:path';
9
+ import process from 'node:process';
10
+
11
+ // Minimal inline patterns for when @harness-engineering/core isn't available.
12
+ // Keep in sync with @harness-engineering/core injection-patterns.ts ALL_PATTERNS.
13
+ // Covers all HIGH-severity patterns and key MEDIUM patterns for degraded-mode safety.
14
+ function inlineScan(text) {
15
+ console.error('[sentinel] Running in degraded mode: core import failed, using inline patterns');
16
+ const findings = [];
17
+ const lines = text.split('\n');
18
+ for (let i = 0; i < lines.length; i++) {
19
+ const line = lines[i];
20
+ // HIGH: INJ-UNI-001 — Zero-width characters
21
+ // eslint-disable-next-line no-misleading-character-class -- intentional: detects zero-width chars for security
22
+ if (/[\u200B\u200C\u200D\uFEFF\u2060]/.test(line)) {
23
+ findings.push({ severity: 'high', ruleId: 'INJ-UNI-001', match: line.trim(), line: i + 1 });
24
+ }
25
+ // HIGH: INJ-UNI-002 — RTL/LTR override characters
26
+ if (/[\u202A-\u202E\u2066-\u2069]/.test(line)) {
27
+ findings.push({ severity: 'high', ruleId: 'INJ-UNI-002', match: line.trim(), line: i + 1 });
28
+ }
29
+ // HIGH: INJ-REROL-001 — Ignore previous instructions
30
+ if (/(?:ignore|disregard|forget)\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?|context|rules?|guidelines?)/i.test(line)) {
31
+ findings.push({ severity: 'high', ruleId: 'INJ-REROL-001', match: line.trim(), line: i + 1 });
32
+ }
33
+ // HIGH: INJ-REROL-002 — Role reassignment
34
+ if (/you\s+are\s+now\s+(?:a\s+|an\s+)?(?:new\s+)?(?:helpful\s+)?(?:my\s+)?(?:\w+\s+)?(?:assistant|agent|AI|bot|chatbot|system|persona)\b/i.test(line)) {
35
+ findings.push({ severity: 'high', ruleId: 'INJ-REROL-002', match: line.trim(), line: i + 1 });
36
+ }
37
+ // HIGH: INJ-REROL-003 — Direct instruction override
38
+ if (/(?:new\s+)?(?:system\s+)?(?:instruction|directive|role|persona)\s*[:=]\s*/i.test(line)) {
39
+ findings.push({ severity: 'high', ruleId: 'INJ-REROL-003', match: line.trim(), line: i + 1 });
40
+ }
41
+ // HIGH: INJ-PERM-001 — Enable all tools/permissions
42
+ if (/(?:allow|enable|grant)\s+all\s+(?:tools?|permissions?|access)/i.test(line)) {
43
+ findings.push({ severity: 'high', ruleId: 'INJ-PERM-001', match: line.trim(), line: i + 1 });
44
+ }
45
+ // HIGH: INJ-PERM-002 — Disable safety/security
46
+ if (/(?:disable|turn\s+off|remove|bypass)\s+(?:all\s+)?(?:safety|security|restrictions?|guardrails?|protections?|checks?)/i.test(line)) {
47
+ findings.push({ severity: 'high', ruleId: 'INJ-PERM-002', match: line.trim(), line: i + 1 });
48
+ }
49
+ // HIGH: INJ-PERM-003 — Auto-approve directive
50
+ if (/(?:auto[- ]?approve|--no-verify|--dangerously-skip-permissions)/i.test(line)) {
51
+ findings.push({ severity: 'high', ruleId: 'INJ-PERM-003', match: line.trim(), line: i + 1 });
52
+ }
53
+ // HIGH: INJ-ENC-001 — Suspicious base64
54
+ if (/(?<!Bearer\s)(?<![:])(?<![A-Za-z0-9/])(?!eyJ)(?:[A-Za-z0-9+/]{4}){7,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?(?![A-Za-z0-9/])/.test(line)) {
55
+ findings.push({ severity: 'high', ruleId: 'INJ-ENC-001', match: line.trim(), line: i + 1 });
56
+ }
57
+ // MEDIUM: INJ-CTX-001 — System prompt claims
58
+ if (/(?:the\s+)?(?:system\s+prompt|system\s+message|hidden\s+instructions?)\s+(?:says?|tells?|instructs?|contains?|is)/i.test(line)) {
59
+ findings.push({ severity: 'medium', ruleId: 'INJ-CTX-001', match: line.trim(), line: i + 1 });
60
+ }
61
+ }
62
+ return findings;
63
+ }
64
+
65
+ async function main() {
66
+ let raw = '';
67
+ try {
68
+ raw = readFileSync(0, 'utf-8');
69
+ } catch {
70
+ process.exit(0);
71
+ }
72
+
73
+ if (!raw.trim()) {
74
+ process.exit(0);
75
+ }
76
+
77
+ let input;
78
+ try {
79
+ input = JSON.parse(raw);
80
+ } catch {
81
+ process.exit(0);
82
+ }
83
+
84
+ try {
85
+ const toolName = input?.tool_name ?? '';
86
+ const toolOutput = input?.tool_output ?? '';
87
+ const sessionId = input?.session_id;
88
+ const workspaceRoot = process.cwd();
89
+
90
+ if (!toolOutput || typeof toolOutput !== 'string') {
91
+ process.exit(0);
92
+ }
93
+
94
+ let findings;
95
+ try {
96
+ const core = await import('@harness-engineering/core');
97
+ findings = core.scanForInjection(toolOutput);
98
+ } catch {
99
+ findings = inlineScan(toolOutput);
100
+ }
101
+
102
+ const actionable = findings.filter((f) => f.severity === 'high' || f.severity === 'medium');
103
+
104
+ if (actionable.length > 0) {
105
+ try {
106
+ const core = await import('@harness-engineering/core');
107
+ core.writeTaint(
108
+ workspaceRoot,
109
+ sessionId,
110
+ `Injection pattern detected in PostToolUse:${toolName} result`,
111
+ actionable,
112
+ `PostToolUse:${toolName}`
113
+ );
114
+ } catch {
115
+ // Fallback inline taint writer — merges with existing taint state
116
+ try {
117
+ const id = sessionId || 'default';
118
+ const taintPath = resolve(workspaceRoot, '.harness', `session-taint-${id}.json`);
119
+ mkdirSync(dirname(taintPath), { recursive: true });
120
+ const now = new Date().toISOString();
121
+ const maxSev = actionable.some((f) => f.severity === 'high') ? 'high' : 'medium';
122
+ const newFindings = actionable.map((f) => ({
123
+ ruleId: f.ruleId, severity: f.severity, match: f.match,
124
+ source: `PostToolUse:${toolName}`, detectedAt: now,
125
+ }));
126
+ // Read and merge existing taint state to preserve earlier taintedAt and findings
127
+ let existing = null;
128
+ try { existing = JSON.parse(readFileSync(taintPath, 'utf-8')); } catch { /* no existing */ }
129
+ const state = {
130
+ sessionId: id,
131
+ taintedAt: existing?.taintedAt ?? now,
132
+ expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
133
+ reason: existing?.reason ?? `Injection pattern detected in PostToolUse:${toolName} result`,
134
+ severity: maxSev,
135
+ findings: [...(existing?.findings ?? []), ...newFindings],
136
+ };
137
+ writeFileSync(taintPath, JSON.stringify(state, null, 2) + '\n');
138
+ } catch { /* best-effort */ }
139
+ }
140
+
141
+ for (const f of actionable) {
142
+ process.stderr.write(
143
+ `Sentinel [${f.severity}] ${f.ruleId}: detected in ${toolName} output\n`
144
+ );
145
+ }
146
+ }
147
+
148
+ const low = findings.filter((f) => f.severity === 'low');
149
+ for (const f of low) {
150
+ process.stderr.write(`Sentinel [low] ${f.ruleId}: ${f.match.slice(0, 80)}\n`);
151
+ }
152
+
153
+ process.exit(0);
154
+ } catch {
155
+ process.exit(0);
156
+ }
157
+ }
158
+
159
+ main();
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /* global console */
3
+ // sentinel-pre.js — PreToolUse:* hook
4
+ // Sentinel prompt injection defense — scans tool inputs for injection patterns
5
+ // and blocks destructive operations during tainted sessions.
6
+ // Exit codes: 0 = allow, 2 = block
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, realpathSync } from 'node:fs';
9
+ import { resolve, dirname } from 'node:path';
10
+ import process from 'node:process';
11
+
12
+ // Destructive tool patterns blocked during taint.
13
+ // These are intentionally inline — this check runs before the @harness-engineering/core
14
+ // import attempt to ensure enforcement even when core is unavailable.
15
+ // Keep in sync with DESTRUCTIVE_BASH exported from @harness-engineering/core injection-patterns.ts.
16
+ const DESTRUCTIVE_BASH = [
17
+ /\bgit\s+push\b/,
18
+ /\bgit\s+commit\b/,
19
+ /\brm\s+-rf?\b/,
20
+ /\brm\s+-r\b/,
21
+ ];
22
+
23
+ function isDestructiveBash(command) {
24
+ return DESTRUCTIVE_BASH.some((p) => p.test(command));
25
+ }
26
+
27
+ function isOutsideWorkspace(filePath, workspaceRoot) {
28
+ if (!filePath || !workspaceRoot) return false;
29
+ const resolved = resolve(workspaceRoot, filePath);
30
+ // Resolve symlinks to prevent bypass via symlink pointing outside workspace
31
+ let realResolved = resolved;
32
+ try { realResolved = realpathSync(resolved); } catch { /* path doesn't exist yet — use resolved */ }
33
+ return !realResolved.startsWith(workspaceRoot);
34
+ }
35
+
36
+ // Minimal inline patterns for when @harness-engineering/core isn't available.
37
+ // Keep in sync with @harness-engineering/core injection-patterns.ts ALL_PATTERNS.
38
+ // Covers all HIGH-severity patterns and key MEDIUM patterns for degraded-mode safety.
39
+ function inlineScan(text) {
40
+ console.error('[sentinel] Running in degraded mode: core import failed, using inline patterns');
41
+ const findings = [];
42
+ const lines = text.split('\n');
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const line = lines[i];
45
+ // HIGH: INJ-UNI-001 — Zero-width characters
46
+ // eslint-disable-next-line no-misleading-character-class -- intentional: detects zero-width chars for security
47
+ if (/[\u200B\u200C\u200D\uFEFF\u2060]/.test(line)) {
48
+ findings.push({ severity: 'high', ruleId: 'INJ-UNI-001', match: line.trim(), line: i + 1 });
49
+ }
50
+ // HIGH: INJ-UNI-002 — RTL/LTR override characters
51
+ if (/[\u202A-\u202E\u2066-\u2069]/.test(line)) {
52
+ findings.push({ severity: 'high', ruleId: 'INJ-UNI-002', match: line.trim(), line: i + 1 });
53
+ }
54
+ // HIGH: INJ-REROL-001 — Ignore previous instructions
55
+ if (/(?:ignore|disregard|forget)\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?|context|rules?|guidelines?)/i.test(line)) {
56
+ findings.push({ severity: 'high', ruleId: 'INJ-REROL-001', match: line.trim(), line: i + 1 });
57
+ }
58
+ // HIGH: INJ-REROL-002 — Role reassignment
59
+ if (/you\s+are\s+now\s+(?:a\s+|an\s+)?(?:new\s+)?(?:helpful\s+)?(?:my\s+)?(?:\w+\s+)?(?:assistant|agent|AI|bot|chatbot|system|persona)\b/i.test(line)) {
60
+ findings.push({ severity: 'high', ruleId: 'INJ-REROL-002', match: line.trim(), line: i + 1 });
61
+ }
62
+ // HIGH: INJ-REROL-003 — Direct instruction override
63
+ if (/(?:new\s+)?(?:system\s+)?(?:instruction|directive|role|persona)\s*[:=]\s*/i.test(line)) {
64
+ findings.push({ severity: 'high', ruleId: 'INJ-REROL-003', match: line.trim(), line: i + 1 });
65
+ }
66
+ // HIGH: INJ-PERM-001 — Enable all tools/permissions
67
+ if (/(?:allow|enable|grant)\s+all\s+(?:tools?|permissions?|access)/i.test(line)) {
68
+ findings.push({ severity: 'high', ruleId: 'INJ-PERM-001', match: line.trim(), line: i + 1 });
69
+ }
70
+ // HIGH: INJ-PERM-002 — Disable safety/security
71
+ if (/(?:disable|turn\s+off|remove|bypass)\s+(?:all\s+)?(?:safety|security|restrictions?|guardrails?|protections?|checks?)/i.test(line)) {
72
+ findings.push({ severity: 'high', ruleId: 'INJ-PERM-002', match: line.trim(), line: i + 1 });
73
+ }
74
+ // HIGH: INJ-PERM-003 — Auto-approve directive
75
+ if (/(?:auto[- ]?approve|--no-verify|--dangerously-skip-permissions)/i.test(line)) {
76
+ findings.push({ severity: 'high', ruleId: 'INJ-PERM-003', match: line.trim(), line: i + 1 });
77
+ }
78
+ // HIGH: INJ-ENC-001 — Suspicious base64
79
+ if (/(?<!Bearer\s)(?<![:])(?<![A-Za-z0-9/])(?!eyJ)(?:[A-Za-z0-9+/]{4}){7,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?(?![A-Za-z0-9/])/.test(line)) {
80
+ findings.push({ severity: 'high', ruleId: 'INJ-ENC-001', match: line.trim(), line: i + 1 });
81
+ }
82
+ // MEDIUM: INJ-CTX-001 — System prompt claims
83
+ if (/(?:the\s+)?(?:system\s+prompt|system\s+message|hidden\s+instructions?)\s+(?:says?|tells?|instructs?|contains?|is)/i.test(line)) {
84
+ findings.push({ severity: 'medium', ruleId: 'INJ-CTX-001', match: line.trim(), line: i + 1 });
85
+ }
86
+ }
87
+ return findings;
88
+ }
89
+
90
+ function extractText(toolName, toolInput) {
91
+ if (toolName === 'Bash') return toolInput?.command ?? '';
92
+ if (toolName === 'Write') return toolInput?.content ?? '';
93
+ if (toolName === 'Edit') return `${toolInput?.old_string ?? ''}\n${toolInput?.new_string ?? ''}`;
94
+ if (toolName === 'Read') return toolInput?.file_path ?? '';
95
+ const parts = [];
96
+ for (const value of Object.values(toolInput || {})) {
97
+ if (typeof value === 'string') parts.push(value);
98
+ }
99
+ return parts.join('\n') || null;
100
+ }
101
+
102
+ async function main() {
103
+ let raw = '';
104
+ try {
105
+ raw = readFileSync(0, 'utf-8');
106
+ } catch {
107
+ process.exit(0);
108
+ }
109
+
110
+ if (!raw.trim()) {
111
+ process.exit(0);
112
+ }
113
+
114
+ let input;
115
+ try {
116
+ input = JSON.parse(raw);
117
+ } catch {
118
+ process.exit(0);
119
+ }
120
+
121
+ try {
122
+ const toolName = input?.tool_name ?? '';
123
+ const toolInput = input?.tool_input ?? {};
124
+ const sessionId = input?.session_id;
125
+ const workspaceRoot = process.cwd();
126
+
127
+ // Step 1: Check taint state — block destructive ops if tainted
128
+ let tainted = false;
129
+ try {
130
+ const taintPath = resolve(
131
+ workspaceRoot,
132
+ '.harness',
133
+ `session-taint-${sessionId || 'default'}.json`
134
+ );
135
+ const taintRaw = readFileSync(taintPath, 'utf-8');
136
+ const taintState = JSON.parse(taintRaw);
137
+
138
+ const expiresAt = new Date(taintState.expiresAt);
139
+ if (new Date() >= expiresAt) {
140
+ try { unlinkSync(taintPath); } catch { /* ignore */ }
141
+ process.stderr.write(
142
+ 'Sentinel: session taint expired. Destructive operations re-enabled.\n'
143
+ );
144
+ } else {
145
+ tainted = true;
146
+ }
147
+ } catch {
148
+ // No taint file or malformed — not tainted
149
+ }
150
+
151
+ if (tainted) {
152
+ if (toolName === 'Bash') {
153
+ const command = toolInput?.command ?? '';
154
+ if (isDestructiveBash(command)) {
155
+ process.stderr.write(
156
+ `BLOCKED by Sentinel: "${toolName}" blocked during tainted session. ` +
157
+ `Destructive operations are restricted. Run "harness taint clear" to lift.\n`
158
+ );
159
+ process.exit(2);
160
+ }
161
+ }
162
+
163
+ if (toolName === 'Write' || toolName === 'Edit') {
164
+ const filePath = toolInput?.file_path ?? '';
165
+ if (isOutsideWorkspace(filePath, workspaceRoot)) {
166
+ process.stderr.write(
167
+ `BLOCKED by Sentinel: "${toolName}" to "${filePath}" blocked during tainted session. ` +
168
+ `File is outside workspace. Run "harness taint clear" to lift.\n`
169
+ );
170
+ process.exit(2);
171
+ }
172
+ }
173
+ }
174
+
175
+ // Step 2: Scan tool inputs for injection patterns
176
+ const textToScan = extractText(toolName, toolInput);
177
+ if (textToScan) {
178
+ let findings;
179
+ try {
180
+ const core = await import('@harness-engineering/core');
181
+ findings = core.scanForInjection(textToScan);
182
+ } catch {
183
+ findings = inlineScan(textToScan);
184
+ }
185
+
186
+ const actionable = findings.filter((f) => f.severity === 'high' || f.severity === 'medium');
187
+
188
+ if (actionable.length > 0) {
189
+ try {
190
+ const core = await import('@harness-engineering/core');
191
+ core.writeTaint(
192
+ workspaceRoot,
193
+ sessionId,
194
+ `Injection pattern detected in PreToolUse:${toolName} input`,
195
+ actionable,
196
+ `PreToolUse:${toolName}`
197
+ );
198
+ } catch {
199
+ // Fallback inline taint writer — merges with existing taint state
200
+ try {
201
+ const id = sessionId || 'default';
202
+ const taintPath = resolve(workspaceRoot, '.harness', `session-taint-${id}.json`);
203
+ mkdirSync(dirname(taintPath), { recursive: true });
204
+ const now = new Date().toISOString();
205
+ const maxSev = actionable.some((f) => f.severity === 'high') ? 'high' : 'medium';
206
+ const newFindings = actionable.map((f) => ({
207
+ ruleId: f.ruleId, severity: f.severity, match: f.match,
208
+ source: `PreToolUse:${toolName}`, detectedAt: now,
209
+ }));
210
+ // Read and merge existing taint state to preserve earlier taintedAt and findings
211
+ let existing = null;
212
+ try { existing = JSON.parse(readFileSync(taintPath, 'utf-8')); } catch { /* no existing */ }
213
+ const state = {
214
+ sessionId: id,
215
+ taintedAt: existing?.taintedAt ?? now,
216
+ expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
217
+ reason: existing?.reason ?? `Injection pattern detected in PreToolUse:${toolName} input`,
218
+ severity: maxSev,
219
+ findings: [...(existing?.findings ?? []), ...newFindings],
220
+ };
221
+ writeFileSync(taintPath, JSON.stringify(state, null, 2) + '\n');
222
+ } catch { /* best-effort */ }
223
+ }
224
+
225
+ for (const f of actionable) {
226
+ process.stderr.write(
227
+ `Sentinel [${f.severity}] ${f.ruleId}: detected in ${toolName} input\n`
228
+ );
229
+ }
230
+ }
231
+
232
+ const low = findings.filter((f) => f.severity === 'low');
233
+ for (const f of low) {
234
+ process.stderr.write(`Sentinel [low] ${f.ruleId}: ${f.match.slice(0, 80)}\n`);
235
+ }
236
+ }
237
+
238
+ process.exit(0);
239
+ } catch {
240
+ process.exit(0);
241
+ }
242
+ }
243
+
244
+ main();
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ /* global setTimeout, fetch, AbortSignal */
3
+ // telemetry-reporter.js — Stop:* hook
4
+ // Reads adoption.jsonl, resolves consent, sends telemetry events to PostHog,
5
+ // and shows a one-time first-run privacy notice.
6
+ // Exit codes: 0 = allow (always, log-only hook — never blocks session teardown)
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { randomUUID } from 'node:crypto';
11
+ import process from 'node:process';
12
+
13
+ // PostHog project API key — public, write-only (cannot read data)
14
+ const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY ?? 'phc_wNTdCMcfJXZPgdNeDociZW6vwoGGo4nb7vqEfWThFfsG'; // harness-ignore SEC-SEC-002: public PostHog write-only ingest key
15
+ const POSTHOG_BATCH_URL = 'https://app.posthog.com/batch';
16
+ const MAX_ATTEMPTS = 3;
17
+ const TIMEOUT_MS = 5000;
18
+
19
+ const FIRST_RUN_NOTICE = `Harness collects anonymous usage analytics to improve the tool.
20
+ No personal information is sent. Disable with:
21
+ DO_NOT_TRACK=1 or harness.config.json \u2192 telemetry.enabled: false\n`;
22
+
23
+ // --- Helpers ---
24
+
25
+ function readJsonSafe(filePath) {
26
+ try {
27
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function sleep(ms) {
34
+ return new Promise((resolve) => setTimeout(resolve, ms));
35
+ }
36
+
37
+ // --- Consent ---
38
+
39
+ function resolveConsent(cwd) {
40
+ if (process.env.DO_NOT_TRACK === '1') return { allowed: false };
41
+ if (process.env.HARNESS_TELEMETRY_OPTOUT === '1') return { allowed: false };
42
+
43
+ const config = readJsonSafe(join(cwd, 'harness.config.json'));
44
+ if (config?.telemetry?.enabled === false) return { allowed: false };
45
+
46
+ const installId = getOrCreateInstallId(cwd);
47
+
48
+ const identityFile = readJsonSafe(join(cwd, '.harness', 'telemetry.json'));
49
+ const identity = {};
50
+ if (identityFile?.identity) {
51
+ if (typeof identityFile.identity.project === 'string') identity.project = identityFile.identity.project;
52
+ if (typeof identityFile.identity.team === 'string') identity.team = identityFile.identity.team;
53
+ if (typeof identityFile.identity.alias === 'string') identity.alias = identityFile.identity.alias;
54
+ }
55
+
56
+ return { allowed: true, installId, identity };
57
+ }
58
+
59
+ // --- Install ID ---
60
+
61
+ const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
62
+
63
+ function getOrCreateInstallId(cwd) {
64
+ const harnessDir = join(cwd, '.harness');
65
+ const installIdFile = join(harnessDir, '.install-id');
66
+
67
+ try {
68
+ const existing = readFileSync(installIdFile, 'utf-8').trim();
69
+ if (UUID_V4_RE.test(existing)) return existing;
70
+ } catch {
71
+ // File does not exist
72
+ }
73
+
74
+ const id = randomUUID();
75
+ mkdirSync(harnessDir, { recursive: true });
76
+ writeFileSync(installIdFile, id, { encoding: 'utf-8', mode: 0o600 });
77
+ return id;
78
+ }
79
+
80
+ // --- Collector ---
81
+
82
+ function readAdoptionRecords(cwd) {
83
+ const adoptionFile = join(cwd, '.harness', 'metrics', 'adoption.jsonl');
84
+ let raw;
85
+ try {
86
+ raw = readFileSync(adoptionFile, 'utf-8');
87
+ } catch {
88
+ return [];
89
+ }
90
+
91
+ const records = [];
92
+ for (const line of raw.split('\n')) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed) continue;
95
+ try {
96
+ const parsed = JSON.parse(trimmed);
97
+ if (
98
+ typeof parsed.skill === 'string' &&
99
+ typeof parsed.startedAt === 'string' &&
100
+ typeof parsed.duration === 'number' &&
101
+ typeof parsed.outcome === 'string' &&
102
+ Array.isArray(parsed.phasesReached)
103
+ ) {
104
+ records.push(parsed);
105
+ }
106
+ } catch {
107
+ // Skip malformed lines
108
+ }
109
+ }
110
+ return records;
111
+ }
112
+
113
+ function collectEvents(cwd, consent) {
114
+ const records = readAdoptionRecords(cwd);
115
+ if (records.length === 0) return [];
116
+
117
+ const { installId, identity } = consent;
118
+ const distinctId = identity.alias ?? installId;
119
+
120
+ return records.map((record) => ({
121
+ event: 'skill_invocation',
122
+ distinctId,
123
+ timestamp: record.startedAt,
124
+ properties: {
125
+ installId,
126
+ os: process.platform,
127
+ nodeVersion: process.version,
128
+ harnessVersion: readHarnessVersion(cwd),
129
+ skillName: record.skill,
130
+ duration: record.duration,
131
+ outcome: record.outcome === 'completed' ? 'success' : 'failure',
132
+ phasesReached: record.phasesReached,
133
+ ...(identity.project ? { project: identity.project } : {}),
134
+ ...(identity.team ? { team: identity.team } : {}),
135
+ },
136
+ }));
137
+ }
138
+
139
+ function readHarnessVersion(cwd) {
140
+ try {
141
+ const pkg = readJsonSafe(join(cwd, 'node_modules', '@harness-engineering', 'core', 'package.json'));
142
+ return pkg?.version ?? 'unknown';
143
+ } catch {
144
+ return 'unknown';
145
+ }
146
+ }
147
+
148
+ // --- Transport ---
149
+
150
+ async function sendEvents(events) {
151
+ if (events.length === 0) return;
152
+
153
+ const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch: events });
154
+
155
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
156
+ try {
157
+ const res = await fetch(POSTHOG_BATCH_URL, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: payload,
161
+ signal: AbortSignal.timeout(TIMEOUT_MS),
162
+ });
163
+ if (res.ok) return;
164
+ if (res.status < 500) return; // 4xx = permanent, do not retry
165
+ } catch {
166
+ // Network error or timeout — retry
167
+ }
168
+ if (attempt < MAX_ATTEMPTS - 1) {
169
+ await sleep(1000 * (attempt + 1));
170
+ }
171
+ }
172
+ // Silent failure — all retries exhausted
173
+ }
174
+
175
+ // --- First-run notice ---
176
+
177
+ function showFirstRunNotice(cwd) {
178
+ const flagFile = join(cwd, '.harness', '.telemetry-notice-shown');
179
+ if (existsSync(flagFile)) return;
180
+
181
+ process.stderr.write(FIRST_RUN_NOTICE);
182
+
183
+ try {
184
+ mkdirSync(join(cwd, '.harness'), { recursive: true });
185
+ writeFileSync(flagFile, new Date().toISOString(), { encoding: 'utf-8' });
186
+ } catch {
187
+ // Non-fatal — notice will show again next time
188
+ }
189
+ }
190
+
191
+ // --- Main ---
192
+
193
+ async function main() {
194
+ let raw = '';
195
+ try {
196
+ raw = readFileSync(0, 'utf-8');
197
+ } catch {
198
+ process.exit(0);
199
+ }
200
+
201
+ if (!raw.trim()) {
202
+ process.exit(0);
203
+ }
204
+
205
+ // Parse stdin (stop hook receives session JSON)
206
+ try {
207
+ JSON.parse(raw);
208
+ } catch {
209
+ process.stderr.write('[telemetry-reporter] Could not parse stdin — skipping\n');
210
+ process.exit(0);
211
+ }
212
+
213
+ try {
214
+ const cwd = process.cwd();
215
+ const consent = resolveConsent(cwd);
216
+
217
+ if (!consent.allowed) {
218
+ process.exit(0);
219
+ }
220
+
221
+ // Show first-run notice (before sending, so user sees it even if send fails)
222
+ showFirstRunNotice(cwd);
223
+
224
+ const events = collectEvents(cwd, consent);
225
+ if (events.length === 0) {
226
+ process.stderr.write('[telemetry-reporter] No adoption records to report\n');
227
+ process.exit(0);
228
+ }
229
+
230
+ await sendEvents(events);
231
+
232
+ // Truncate adoption.jsonl after successful send to prevent re-sending
233
+ const adoptionFile = join(cwd, '.harness', 'metrics', 'adoption.jsonl');
234
+ try {
235
+ writeFileSync(adoptionFile, '', 'utf-8');
236
+ } catch {
237
+ // Non-fatal — records may be re-sent next session
238
+ }
239
+
240
+ process.stderr.write(`[telemetry-reporter] Sent ${events.length} telemetry event(s)\n`);
241
+ process.exit(0);
242
+ } catch (err) {
243
+ process.stderr.write(`[telemetry-reporter] Failed: ${err.message}\n`);
244
+ process.exit(0);
245
+ }
246
+ }
247
+
248
+ main();