@prodcycle/prodcycle 0.6.10 → 0.6.12

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.
package/LICENSE CHANGED
@@ -1,52 +1,201 @@
1
- Copyright (c) 2025-2026 ProdCycle, Inc. All rights reserved.
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
2
4
 
3
- This software and associated documentation files (the "Software") are the
4
- proprietary property of ProdCycle, Inc. and are protected by copyright law.
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
5
6
 
6
- GRANT OF LICENSE
7
+ 1. Definitions.
7
8
 
8
- Subject to valid license key activation and the terms of your ProdCycle
9
- subscription agreement, ProdCycle, Inc. grants you a limited, non-exclusive,
10
- non-transferable, revocable license to use the Software solely for your
11
- internal business purposes.
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
12
11
 
13
- RESTRICTIONS
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
14
 
15
- You may not, without the prior written consent of ProdCycle, Inc.:
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
16
22
 
17
- 1. Copy, modify, or create derivative works of the Software;
18
- 2. Distribute, sublicense, lease, rent, or lend the Software to any
19
- third party;
20
- 3. Reverse engineer, decompile, or disassemble the Software;
21
- 4. Remove or alter any proprietary notices, labels, or marks on the
22
- Software;
23
- 5. Use the Software to build a competing product or service.
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
24
25
 
25
- COMPLIANCE POLICIES
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
26
29
 
27
- The compliance policy definitions (Rego rules, Cedar policies, and framework
28
- control mappings) included in the `policies/` and `frameworks/` directories
29
- are provided as part of the Software and are subject to the same license
30
- terms.
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
31
34
 
32
- DISCLAIMER OF WARRANTIES
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
33
39
 
34
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
37
47
 
38
- LIMITATION OF LIABILITY
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
39
61
 
40
- IN NO EVENT SHALL PRODCYCLE, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
42
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43
- SOFTWARE.
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
44
65
 
45
- TERMINATION
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
46
72
 
47
- This license is effective until terminated. ProdCycle, Inc. may terminate this
48
- license at any time if you fail to comply with any term of this agreement.
49
- Upon termination, you must destroy all copies of the Software in your
50
- possession.
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
51
88
 
52
- For licensing inquiries, contact: licensing@prodcycle.com
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2025-2026 ProdCycle, Inc.
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -94,15 +94,27 @@ if (!result.passed) {
94
94
  An API key is required for production use to authenticate with ProdCycle. Set it via environment variable:
95
95
 
96
96
  ```bash
97
- export PC_API_KEY=pc_your_api_key_here
97
+ export PRODCYCLE_API_KEY=pc_your_api_key_here
98
98
  ```
99
99
 
100
100
  API keys are created through the ProdCycle dashboard.
101
101
 
102
+ ## Exit codes
103
+
104
+ `scan`, `gate`, and `hook` use exit codes designed for CI gates and agent hooks:
105
+
106
+ | Code | Meaning |
107
+ | --- | --- |
108
+ | `0` | Pass — no findings at or above the `--fail-on` threshold (default: `critical,high`). |
109
+ | `1` | Findings present — code is not clean. |
110
+ | `2` | Scanner unavailable or usage error — the scan could not be certified either way (fail-closed). Distinguish this from `1` so CI/agents can treat "fix your code" differently from "investigate the scanner." |
111
+
112
+ > The Claude Code `hook` path is special-cased: a missing/rejected API key or a clean result exits `0` so the agent is never blocked on setup; violations are returned to the agent as structured feedback.
113
+
102
114
  ## Requirements
103
115
 
104
- - Node.js >= 24.0.0
116
+ - Node.js >= 22.0.0
105
117
 
106
118
  ## License
107
119
 
108
- MIT
120
+ Apache-2.0 — see [LICENSE](./LICENSE).
@@ -88,7 +88,7 @@ export declare class ComplianceApiClient {
88
88
  * '/v1/compliance/scans'`, silently falls back to the chunked-session
89
89
  * flow so large-repo CI jobs don't have to know the difference.
90
90
  */
91
- validate(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
91
+ validate(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
92
92
  /**
93
93
  * Hook endpoint — small per-write call from coding agents. No
94
94
  * suggestedEndpoint fallback because /hook keeps the historical 50 MB
@@ -102,7 +102,7 @@ export declare class ComplianceApiClient {
102
102
  * 30 minutes by default — abandoned sessions self-clean via the
103
103
  * stale-session reaper.
104
104
  */
105
- openSession(frameworks: string[], options?: ScanOptions): Promise<{
105
+ openSession(frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<{
106
106
  scanId: string;
107
107
  chunkSizeBytes: number;
108
108
  maxFilesPerChunk: number;
@@ -131,7 +131,7 @@ export declare class ComplianceApiClient {
131
131
  * Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
132
132
  * to override the conservative defaults.
133
133
  */
134
- validateChunked(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
134
+ validateChunked(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
135
135
  /**
136
136
  * Some server versions of `POST /scans/:id/complete` return only the summary,
137
137
  * leaving `findings` empty even when `summary.total > 0`. The findings are
@@ -153,7 +153,7 @@ export declare class ComplianceApiClient {
153
153
  * `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
154
154
  * runners that don't want to hold a connection for a 60 s scan.
155
155
  */
156
- validateAsync(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<{
156
+ validateAsync(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<{
157
157
  scanId: string;
158
158
  }>;
159
159
  /**
@@ -164,7 +164,7 @@ export declare class ComplianceApiClient {
164
164
  * High-level helper: kicks off an async-validate, polls until terminal,
165
165
  * returns the same shape as `validate()`.
166
166
  */
167
- validateAndPoll(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
167
+ validateAndPoll(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
168
168
  private buildOptions;
169
169
  /**
170
170
  * Single HTTP request with:
@@ -105,10 +105,15 @@ class ComplianceApiClient {
105
105
  const config = (0, config_1.readConfig)();
106
106
  this.apiUrl = (0, config_1.resolveApiUrl)(apiUrl, config) || DEFAULT_API_URL;
107
107
  this.apiKey = (0, config_1.resolveApiKey)(apiKey, config);
108
- if (!this.apiKey &&
109
- process.env.NODE_ENV !== 'test' &&
110
- !process.env.PC_SUPPRESS_WARNINGS) {
111
- process.stderr.write('Warning: PC_API_KEY is not set. API calls will likely fail.\n');
108
+ // Fail fast with an actionable error rather than warning and letting the
109
+ // request fall over later as an opaque `fetch failed` / 401. A missing key
110
+ // is a setup problem the user can fix immediately. The Claude Code hook
111
+ // path pre-checks `resolveApiKey` and exits 0 *before* constructing a
112
+ // client, so this throw never blocks an agent on every edit.
113
+ if (!this.apiKey) {
114
+ throw new Error('No ProdCycle API key found. Set the PRODCYCLE_API_KEY environment variable ' +
115
+ 'or run `prodcycle init --api-key pc_...` to save one. ' +
116
+ 'See https://docs.prodcycle.com.');
112
117
  }
113
118
  }
114
119
  /**
@@ -116,12 +121,17 @@ class ComplianceApiClient {
116
121
  * '/v1/compliance/scans'`, silently falls back to the chunked-session
117
122
  * flow so large-repo CI jobs don't have to know the difference.
118
123
  */
119
- async validate(files, frameworks, options = {}) {
124
+ async validate(files, frameworks, options = {}, vcsLocalOnly = []) {
120
125
  try {
121
126
  return await this.request('POST', '/v1/compliance/validate', {
122
127
  files,
123
128
  frameworks,
124
129
  options: this.buildOptions(options),
130
+ // Top-level hint (sibling of `files`/`frameworks`, NOT an option) so
131
+ // the scanner can downgrade secrets that exist only in the local
132
+ // working tree. Omitted entirely when empty so payloads are unchanged
133
+ // for repos without local-only files.
134
+ ...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
125
135
  });
126
136
  }
127
137
  catch (err) {
@@ -131,7 +141,7 @@ class ComplianceApiClient {
131
141
  // Server says: this payload won't fit, use chunked sessions instead.
132
142
  // Fall back transparently — the caller asked for `validate`, the
133
143
  // semantics (single scanId with final findings) are preserved.
134
- return this.validateChunked(files, frameworks, options);
144
+ return this.validateChunked(files, frameworks, options, vcsLocalOnly);
135
145
  }
136
146
  throw err;
137
147
  }
@@ -156,10 +166,13 @@ class ComplianceApiClient {
156
166
  * 30 minutes by default — abandoned sessions self-clean via the
157
167
  * stale-session reaper.
158
168
  */
159
- async openSession(frameworks, options = {}) {
169
+ async openSession(frameworks, options = {}, vcsLocalOnly = []) {
160
170
  return this.request('POST', '/v1/compliance/scans', {
161
171
  frameworks,
162
172
  options: this.buildOptions(options),
173
+ // Sent once at session-open (NOT on appendChunk) — the scanner applies
174
+ // it to the whole chunked scan. Omitted when empty (see `validate`).
175
+ ...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
163
176
  });
164
177
  }
165
178
  /**
@@ -187,10 +200,10 @@ class ComplianceApiClient {
187
200
  * Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
188
201
  * to override the conservative defaults.
189
202
  */
190
- async validateChunked(files, frameworks, options = {}) {
203
+ async validateChunked(files, frameworks, options = {}, vcsLocalOnly = []) {
191
204
  const chunkMaxBytes = options.config?.chunkMaxBytes ?? DEFAULT_CHUNK_MAX_BYTES;
192
205
  const chunkMaxFiles = options.config?.chunkMaxFiles ?? DEFAULT_CHUNK_MAX_FILES;
193
- const session = await this.openSession(frameworks, options);
206
+ const session = await this.openSession(frameworks, options, vcsLocalOnly);
194
207
  const chunks = chunkFiles(files, chunkMaxBytes, chunkMaxFiles);
195
208
  for (const chunk of chunks) {
196
209
  await this.appendChunk(session.scanId, chunk);
@@ -276,11 +289,13 @@ class ComplianceApiClient {
276
289
  * `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
277
290
  * runners that don't want to hold a connection for a 60 s scan.
278
291
  */
279
- async validateAsync(files, frameworks, options = {}) {
292
+ async validateAsync(files, frameworks, options = {}, vcsLocalOnly = []) {
280
293
  return this.request('POST', '/v1/compliance/validate?async=true', {
281
294
  files,
282
295
  frameworks,
283
296
  options: this.buildOptions(options),
297
+ // Top-level local-only hint; omitted when empty (see `validate`).
298
+ ...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
284
299
  });
285
300
  }
286
301
  /**
@@ -293,8 +308,8 @@ class ComplianceApiClient {
293
308
  * High-level helper: kicks off an async-validate, polls until terminal,
294
309
  * returns the same shape as `validate()`.
295
310
  */
296
- async validateAndPoll(files, frameworks, options = {}) {
297
- const { scanId } = await this.validateAsync(files, frameworks, options);
311
+ async validateAndPoll(files, frameworks, options = {}, vcsLocalOnly = []) {
312
+ const { scanId } = await this.validateAsync(files, frameworks, options, vcsLocalOnly);
298
313
  const deadline = Date.now() + ASYNC_POLL_TIMEOUT_MS;
299
314
  // Always poll at least once, and always do one final poll *after*
300
315
  // the last sleep before declaring a timeout — otherwise a scan that
package/dist/cli.d.ts CHANGED
@@ -50,15 +50,43 @@ export declare function resolveHookFileKey(inputPath: string, realpathFile: stri
50
50
  ok: false;
51
51
  error: string;
52
52
  };
53
+ /**
54
+ * Cursor hook events that carry `hook_event_name` but are not Claude Code.
55
+ * Cursor and Claude both stamp payloads; only Claude PostToolUse (etc.)
56
+ * should use Claude-Code-native JSON output.
57
+ *
58
+ * @see https://cursor.com/docs/hooks
59
+ */
60
+ export declare const CURSOR_HOOK_EVENT_NAMES: Set<string>;
61
+ /**
62
+ * True when stdin is a Cursor hooks payload (e.g. `afterFileEdit`).
63
+ * Pure so it's unit-testable.
64
+ */
65
+ export declare function isCursorHookPayload(payload: unknown): boolean;
53
66
  /**
54
67
  * True when a parsed `hook` stdin payload is a Claude Code hook event.
55
68
  *
56
- * Claude Code stamps every hook payload with a `hook_event_name` field
57
- * (e.g. `"PostToolUse"`); no other supported input shape carries it. We
58
- * key on its presence to switch `hook` into Claude-Code-native JSON
59
- * output see `formatClaudeHookOutput`. Pure so it's unit-testable.
69
+ * Claude Code stamps hook payloads with `hook_event_name` (e.g.
70
+ * `"PostToolUse"`). Cursor uses the same field for its own events — exclude
71
+ * those so `hook` prints a human-readable report for Cursor's notification
72
+ * hooks instead of Claude's `{"decision":"block",...}` JSON. Pure so it's
73
+ * unit-testable.
60
74
  */
61
75
  export declare function isClaudeCodeHookPayload(payload: unknown): boolean;
76
+ /** True when a hooks.json command invokes `prodcycle hook`. */
77
+ export declare function isProdcycleHookCommand(command: string): boolean;
78
+ /**
79
+ * Build `prodcycle hook` for agent hook configs from a resolved binary path.
80
+ * Quotes paths that need it so hooks.json commands survive spaces in usernames.
81
+ * Pure so it's unit-testable. Mirrors Python `_format_prodcycle_hook_command`.
82
+ */
83
+ export declare function formatProdcycleHookCommand(bin: string): string;
84
+ /**
85
+ * Resolve `prodcycle hook` for agent hook configs. GUI-launched editors
86
+ * (Cursor, Claude Code) often lack nvm/shims on PATH — write the absolute
87
+ * binary path when `which`/`where` finds one.
88
+ */
89
+ export declare function resolveProdcycleHookCommand(): string;
62
90
  /**
63
91
  * Render a `hook` scan result as Claude Code PostToolUse hook JSON.
64
92
  *
package/dist/cli.js CHANGED
@@ -34,9 +34,14 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.CURSOR_HOOK_EVENT_NAMES = void 0;
37
38
  exports.isCiEnvironment = isCiEnvironment;
38
39
  exports.resolveHookFileKey = resolveHookFileKey;
40
+ exports.isCursorHookPayload = isCursorHookPayload;
39
41
  exports.isClaudeCodeHookPayload = isClaudeCodeHookPayload;
42
+ exports.isProdcycleHookCommand = isProdcycleHookCommand;
43
+ exports.formatProdcycleHookCommand = formatProdcycleHookCommand;
44
+ exports.resolveProdcycleHookCommand = resolveProdcycleHookCommand;
40
45
  exports.formatClaudeHookOutput = formatClaudeHookOutput;
41
46
  exports.formatClaudeHookSetupNotice = formatClaudeHookSetupNotice;
42
47
  const commander_1 = require("commander");
@@ -171,8 +176,8 @@ program
171
176
  .option('--include <patterns>', 'Comma-separated glob patterns to include')
172
177
  .option('--exclude <patterns>', 'Comma-separated glob patterns to exclude')
173
178
  .option('--output <file>', 'Write report to file')
174
- .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
175
- .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
179
+ .option('--api-url <url>', 'Compliance API base URL (or PRODCYCLE_API_URL env)')
180
+ .option('--api-key <key>', 'API key for compliance API (or PRODCYCLE_API_KEY env)')
176
181
  .option('--async', 'Use the async-validate flow (server returns 202 immediately; CLI polls until COMPLETED). Useful for large scans where holding a connection isn’t practical.')
177
182
  .option('--chunked', 'Force the chunked-session flow regardless of payload size. The default already auto-falls-back to chunked when /validate returns 413 with a chunked-endpoint suggestion.')
178
183
  .option('--pr <range>', 'Scan only files changed in a git diff range (e.g. "origin/main..HEAD"). Cuts CI scan time on large repos by skipping unchanged files. Requires baseDir to be the git repo root.')
@@ -245,8 +250,8 @@ program
245
250
  .option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2,hipaa,nist-csf')
246
251
  .option('--format <format>', 'Output format: json, sarif, table, prompt', 'prompt')
247
252
  .option('--output <file>', 'Write report to file')
248
- .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
249
- .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
253
+ .option('--api-url <url>', 'Compliance API base URL (or PRODCYCLE_API_URL env)')
254
+ .option('--api-key <key>', 'API key for compliance API (or PRODCYCLE_API_KEY env)')
250
255
  .action(async (opts) => {
251
256
  try {
252
257
  const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
@@ -293,8 +298,8 @@ program
293
298
  .description('Get the status + findings of a scan by ID')
294
299
  .option('--format <format>', 'Output format: json, sarif, table, prompt', 'json')
295
300
  .option('--output <file>', 'Write report to file')
296
- .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
297
- .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
301
+ .option('--api-url <url>', 'Compliance API base URL (or PRODCYCLE_API_URL env)')
302
+ .option('--api-key <key>', 'API key for compliance API (or PRODCYCLE_API_KEY env)')
298
303
  .action(async (scanId, opts) => {
299
304
  try {
300
305
  const format = (opts.format ?? 'json');
@@ -341,13 +346,14 @@ program
341
346
  .option('--file <path>', 'Scan this file from disk (alternative to reading content from stdin)')
342
347
  .option('--fail-on <levels>', 'Severities that cause non-zero exit', 'critical,high')
343
348
  .option('--output <file>', 'Write report to file')
344
- .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
345
- .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
349
+ .option('--api-url <url>', 'Compliance API base URL (or PRODCYCLE_API_URL env)')
350
+ .option('--api-key <key>', 'API key for compliance API (or PRODCYCLE_API_KEY env)')
346
351
  .action(async (opts) => {
347
352
  // Hoisted so the catch block can branch on it (see the auth-error case).
348
353
  let claudeHook = false;
349
354
  try {
350
355
  const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
356
+ const failOn = parseList(opts.failOn) ?? ['critical', 'high'];
351
357
  const format = (opts.format ?? 'prompt');
352
358
  const collected = await collectHookFiles(opts.file);
353
359
  if (!collected || Object.keys(collected.files).length === 0) {
@@ -356,6 +362,7 @@ program
356
362
  return;
357
363
  }
358
364
  claudeHook = collected.claudeHook;
365
+ const cursorHook = collected.cursorHook;
359
366
  // Graceful unconfigured state: a missing API key is a setup problem,
360
367
  // not a compliance failure. For a Claude Code hook, surface a one-line
361
368
  // setup notice and exit 0 so the agent is not blocked on every edit.
@@ -368,6 +375,7 @@ program
368
375
  const response = await (0, index_1.gate)({
369
376
  files: collected.files,
370
377
  frameworks,
378
+ failOn: failOn,
371
379
  apiUrl: opts.apiUrl,
372
380
  apiKey: opts.apiKey,
373
381
  });
@@ -389,7 +397,13 @@ program
389
397
  process.exit(0);
390
398
  return;
391
399
  }
392
- writeOutput(renderReport(response, format), opts.output);
400
+ const report = renderReport(response, format);
401
+ writeOutput(report, opts.output);
402
+ // Cursor's afterFileEdit hook is notification-only (no agent feedback).
403
+ // Mirror violations to stderr so they appear in the Hooks output channel.
404
+ if (cursorHook && report.trim() && !opts.output) {
405
+ process.stderr.write(report.endsWith('\n') ? report : report + '\n');
406
+ }
393
407
  process.exit(response.exitCode);
394
408
  }
395
409
  catch (error) {
@@ -451,18 +465,97 @@ function resolveHookFileKey(inputPath, realpathFile, realpathCwd) {
451
465
  }
452
466
  return { ok: true, key: relative };
453
467
  }
468
+ /**
469
+ * Cursor hook events that carry `hook_event_name` but are not Claude Code.
470
+ * Cursor and Claude both stamp payloads; only Claude PostToolUse (etc.)
471
+ * should use Claude-Code-native JSON output.
472
+ *
473
+ * @see https://cursor.com/docs/hooks
474
+ */
475
+ exports.CURSOR_HOOK_EVENT_NAMES = new Set([
476
+ 'afterFileEdit',
477
+ 'afterTabFileEdit',
478
+ 'afterAgentResponse',
479
+ 'afterAgentThought',
480
+ 'afterMCPExecution',
481
+ 'afterShellExecution',
482
+ 'beforeMCPExecution',
483
+ 'beforeReadFile',
484
+ 'beforeShellExecution',
485
+ 'beforeSubmitPrompt',
486
+ 'beforeTabFileRead',
487
+ 'postToolUse',
488
+ 'postToolUseFailure',
489
+ 'preCompact',
490
+ 'preToolUse',
491
+ 'sessionEnd',
492
+ 'sessionStart',
493
+ 'stop',
494
+ 'subagentStart',
495
+ 'subagentStop',
496
+ 'workspaceOpen',
497
+ ]);
498
+ /**
499
+ * True when stdin is a Cursor hooks payload (e.g. `afterFileEdit`).
500
+ * Pure so it's unit-testable.
501
+ */
502
+ function isCursorHookPayload(payload) {
503
+ if (typeof payload !== 'object' || payload === null)
504
+ return false;
505
+ const name = payload.hook_event_name;
506
+ return typeof name === 'string' && exports.CURSOR_HOOK_EVENT_NAMES.has(name);
507
+ }
454
508
  /**
455
509
  * True when a parsed `hook` stdin payload is a Claude Code hook event.
456
510
  *
457
- * Claude Code stamps every hook payload with a `hook_event_name` field
458
- * (e.g. `"PostToolUse"`); no other supported input shape carries it. We
459
- * key on its presence to switch `hook` into Claude-Code-native JSON
460
- * output see `formatClaudeHookOutput`. Pure so it's unit-testable.
511
+ * Claude Code stamps hook payloads with `hook_event_name` (e.g.
512
+ * `"PostToolUse"`). Cursor uses the same field for its own events — exclude
513
+ * those so `hook` prints a human-readable report for Cursor's notification
514
+ * hooks instead of Claude's `{"decision":"block",...}` JSON. Pure so it's
515
+ * unit-testable.
461
516
  */
462
517
  function isClaudeCodeHookPayload(payload) {
463
- return (typeof payload === 'object' &&
464
- payload !== null &&
465
- typeof payload.hook_event_name === 'string');
518
+ if (typeof payload !== 'object' || payload === null)
519
+ return false;
520
+ const name = payload.hook_event_name;
521
+ if (typeof name !== 'string')
522
+ return false;
523
+ return !exports.CURSOR_HOOK_EVENT_NAMES.has(name);
524
+ }
525
+ /** True when a hooks.json command invokes `prodcycle hook`. */
526
+ function isProdcycleHookCommand(command) {
527
+ const trimmed = command.trim();
528
+ return /\bprodcycle\b/.test(trimmed) && /\bhook\b/.test(trimmed);
529
+ }
530
+ /**
531
+ * Build `prodcycle hook` for agent hook configs from a resolved binary path.
532
+ * Quotes paths that need it so hooks.json commands survive spaces in usernames.
533
+ * Pure so it's unit-testable. Mirrors Python `_format_prodcycle_hook_command`.
534
+ */
535
+ function formatProdcycleHookCommand(bin) {
536
+ const needsQuotes = /[\s"]/.test(bin);
537
+ const executable = needsQuotes
538
+ ? `"${bin.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
539
+ : bin;
540
+ return `${executable} hook`;
541
+ }
542
+ /**
543
+ * Resolve `prodcycle hook` for agent hook configs. GUI-launched editors
544
+ * (Cursor, Claude Code) often lack nvm/shims on PATH — write the absolute
545
+ * binary path when `which`/`where` finds one.
546
+ */
547
+ function resolveProdcycleHookCommand() {
548
+ try {
549
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
550
+ const out = (0, child_process_1.execFileSync)(whichCmd, ['prodcycle'], { encoding: 'utf8' }).trim();
551
+ const bin = out.split(/\r?\n/)[0]?.trim();
552
+ if (bin)
553
+ return formatProdcycleHookCommand(bin);
554
+ }
555
+ catch {
556
+ /* not on PATH */
557
+ }
558
+ return 'prodcycle hook';
466
559
  }
467
560
  /**
468
561
  * Render a `hook` scan result as Claude Code PostToolUse hook JSON.
@@ -502,7 +595,7 @@ function formatClaudeHookSetupNotice(reason = 'missing') {
502
595
  return JSON.stringify({
503
596
  systemMessage: `ProdCycle compliance scanning is inactive — ${detail}. ` +
504
597
  'Run `prodcycle init --api-key pc_...` to save a valid key, or set the ' +
505
- 'PC_API_KEY environment variable. See https://docs.prodcycle.com.',
598
+ 'PRODCYCLE_API_KEY environment variable. See https://docs.prodcycle.com.',
506
599
  });
507
600
  }
508
601
  /**
@@ -531,8 +624,8 @@ async function collectHookFiles(filePath) {
531
624
  console.error(resolved.error);
532
625
  process.exit(2);
533
626
  }
534
- // `--file` is a manual / non-agent invocation — never Claude Code.
535
- return { files: { [resolved.key]: content }, claudeHook: false };
627
+ // `--file` is a manual / non-agent invocation — never Claude Code or Cursor.
628
+ return { files: { [resolved.key]: content }, claudeHook: false, cursorHook: false };
536
629
  }
537
630
  const stdin = await readStdin();
538
631
  if (!stdin.trim()) {
@@ -547,12 +640,11 @@ async function collectHookFiles(filePath) {
547
640
  console.error(`hook: invalid JSON on stdin: ${e.message}`);
548
641
  process.exit(2);
549
642
  }
550
- // Claude Code stamps its hook payloads with `hook_event_name`; detect it
551
- // once here so every return below can flag a Claude Code invocation.
643
+ const cursorHook = isCursorHookPayload(payload);
552
644
  const claudeHook = isClaudeCodeHookPayload(payload);
553
645
  // Shape 1: {"files": {path: content}} — gate-compatible
554
646
  if (payload && typeof payload.files === 'object' && payload.files !== null) {
555
- return { files: payload.files, claudeHook };
647
+ return { files: payload.files, claudeHook, cursorHook };
556
648
  }
557
649
  // Shape 2: top-level single file. Shape 3: Claude Code tool_input nesting.
558
650
  const candidate = payload?.tool_input ?? payload;
@@ -571,9 +663,13 @@ async function collectHookFiles(filePath) {
571
663
  const realpathCwd = fs.realpathSync(process.cwd());
572
664
  const resolved = resolveHookFileKey(hookFilePath, realpathFile, realpathCwd);
573
665
  if (!resolved.ok) {
574
- // The helper's message references `--file`; rewrite for the stdin call site.
575
- console.error(resolved.error.replace('hook: --file ', 'hook: file_path '));
576
- process.exit(2);
666
+ // Out-of-repo edit: the API only accepts repo-relative paths, so a file
667
+ // outside the repo root isn't scannable. Skip it (exit 0 via the caller's
668
+ // empty-collection check) rather than hard-erroring — matches how
669
+ // non-scannable files are handled, and keeps an agent from being blocked
670
+ // when it edits files outside the project (dotfiles, sibling repos, …).
671
+ process.stderr.write(`hook: skipping ${hookFilePath} — outside the repo root (${process.cwd()}).\n`);
672
+ return null;
577
673
  }
578
674
  hookFileKey = resolved.key;
579
675
  }
@@ -588,21 +684,21 @@ async function collectHookFiles(filePath) {
588
684
  }
589
685
  const rel = path.relative(process.cwd(), hookFilePath);
590
686
  if (rel.startsWith('..') || path.isAbsolute(rel)) {
591
- console.error(`hook: file_path ${hookFilePath} is outside the current directory ` +
592
- `(${process.cwd()}). Pass a path relative to the repo root.`);
593
- process.exit(2);
687
+ // Out-of-repo edit (see above) skip rather than block the agent.
688
+ process.stderr.write(`hook: skipping ${hookFilePath} outside the repo root (${process.cwd()}).\n`);
689
+ return null;
594
690
  }
595
691
  hookFileKey = rel;
596
692
  }
597
693
  }
598
694
  if (hookFileKey && typeof hookContent === 'string') {
599
- return { files: { [hookFileKey]: hookContent }, claudeHook };
695
+ return { files: { [hookFileKey]: hookContent }, claudeHook, cursorHook };
600
696
  }
601
697
  if (hookFilePath && hookFileKey && fs.existsSync(hookFilePath)) {
602
698
  // Only a path was given — read from disk so post-edit hooks still work
603
- // when the agent doesn't ship the content inline.
699
+ // when the agent doesn't ship the content inline (Cursor afterFileEdit).
604
700
  const content = fs.readFileSync(hookFilePath, 'utf8');
605
- return { files: { [hookFileKey]: content }, claudeHook };
701
+ return { files: { [hookFileKey]: content }, claudeHook, cursorHook };
606
702
  }
607
703
  console.error('hook: stdin payload not recognized. Expected one of:\n' +
608
704
  ' {"files": {"path": "content"}}\n' +
@@ -726,11 +822,10 @@ function configureAgent(agent, dir, force, writtenPaths) {
726
822
  }
727
823
  }
728
824
  const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
729
- const CLAUDE_COMMAND = 'prodcycle hook';
730
825
  /**
731
826
  * Build the post-install hint about API-key setup. The hook authenticates
732
827
  * against the hosted API using a key resolved from `--api-key`, the
733
- * `PC_API_KEY` env var, or the user config file (see `utils/config.ts`).
828
+ * `PRODCYCLE_API_KEY` env var, or the user config file (see `utils/config.ts`).
734
829
  * The config file is the robust option — unlike an env var, a GUI-launched
735
830
  * editor picks it up with no relaunch — so when no key is found we point
736
831
  * the user there.
@@ -739,7 +834,7 @@ function apiKeyHint() {
739
834
  return (0, config_1.resolveApiKey)()
740
835
  ? 'API key configured ✓'
741
836
  : '⚠ No API key configured — run `prodcycle init --api-key pc_...` to ' +
742
- 'save one (or set the PC_API_KEY env var). The hook calls the hosted ' +
837
+ 'save one (or set the PRODCYCLE_API_KEY env var). The hook calls the hosted ' +
743
838
  'API and fails without it.';
744
839
  }
745
840
  function configureClaudeCode(dir, force) {
@@ -766,7 +861,7 @@ function configureClaudeCode(dir, force) {
766
861
  const hooks = (settings.hooks ??= {});
767
862
  const postToolUse = (hooks.PostToolUse ??= []);
768
863
  // Look for an existing prodcycle entry
769
- const existing = postToolUse.find((b) => b.hooks?.some((h) => h.type === 'command' && h.command.trim().startsWith('prodcycle hook')));
864
+ const existing = postToolUse.find((b) => b.hooks?.some((h) => h.type === 'command' && isProdcycleHookCommand(h.command)));
770
865
  if (existing && !force) {
771
866
  return {
772
867
  status: 'already',
@@ -776,12 +871,12 @@ function configureClaudeCode(dir, force) {
776
871
  if (existing && force) {
777
872
  // Replace in place — preserve the matcher, rewrite the command to the canonical form
778
873
  existing.matcher = CLAUDE_MATCHER;
779
- existing.hooks = [{ type: 'command', command: CLAUDE_COMMAND }];
874
+ existing.hooks = [{ type: 'command', command: resolveProdcycleHookCommand() }];
780
875
  }
781
876
  else {
782
877
  postToolUse.push({
783
878
  matcher: CLAUDE_MATCHER,
784
- hooks: [{ type: 'command', command: CLAUDE_COMMAND }],
879
+ hooks: [{ type: 'command', command: resolveProdcycleHookCommand() }],
785
880
  });
786
881
  }
787
882
  if (!fs.existsSync(claudeDir))
@@ -792,8 +887,8 @@ function configureClaudeCode(dir, force) {
792
887
  message: `[claude] wrote PostToolUse hook to ${settingsPath}. ${apiKeyHint()}`,
793
888
  };
794
889
  }
795
- const CURSOR_COMMAND = 'prodcycle hook';
796
890
  function configureCursor(dir, force) {
891
+ const cursorCommand = resolveProdcycleHookCommand();
797
892
  const cursorDir = path.join(dir, '.cursor');
798
893
  const hooksPath = path.join(cursorDir, 'hooks.json');
799
894
  let config = { version: 1 };
@@ -819,7 +914,7 @@ function configureCursor(dir, force) {
819
914
  config.version = 1;
820
915
  const hooks = (config.hooks ??= {});
821
916
  const afterFileEdit = (hooks.afterFileEdit ??= []);
822
- const existing = afterFileEdit.find((h) => typeof h.command === 'string' && h.command.trim().startsWith('prodcycle hook'));
917
+ const existing = afterFileEdit.find((h) => typeof h.command === 'string' && isProdcycleHookCommand(h.command));
823
918
  if (existing && !force) {
824
919
  return {
825
920
  status: 'already',
@@ -827,10 +922,10 @@ function configureCursor(dir, force) {
827
922
  };
828
923
  }
829
924
  if (existing && force) {
830
- existing.command = CURSOR_COMMAND;
925
+ existing.command = cursorCommand;
831
926
  }
832
927
  else {
833
- afterFileEdit.push({ command: CURSOR_COMMAND });
928
+ afterFileEdit.push({ command: cursorCommand });
834
929
  }
835
930
  if (!fs.existsSync(cursorDir))
836
931
  fs.mkdirSync(cursorDir, { recursive: true });
@@ -949,15 +1044,16 @@ function writeCiFile(provider, dir, relPath, content, force) {
949
1044
  if (!fs.existsSync(parent))
950
1045
  fs.mkdirSync(parent, { recursive: true });
951
1046
  fs.writeFileSync(fullPath, content);
952
- // GitHub uses the `prodcycle/actions/compliance` action, which reads
953
- // its key from `secrets.PRODCYCLE_API_KEY`. GitLab and CircleCI invoke
954
- // the CLI directly, which reads `PC_API_KEY` from the environment.
1047
+ // GitHub uses the `prodcycle/actions/compliance` action, which reads its
1048
+ // key from `secrets.PRODCYCLE_API_KEY`. GitLab and CircleCI invoke the CLI
1049
+ // directly, which reads `PRODCYCLE_API_KEY` from the environment — so the
1050
+ // secret/variable name is the same across all three providers.
955
1051
  const followup = provider === 'gitlab'
956
1052
  ? `Include it from .gitlab-ci.yml: \`include: '${relPath}'\`. `
957
1053
  : provider === 'circleci'
958
1054
  ? `Reference it from .circleci/config.yml or merge the contents in. `
959
1055
  : '';
960
- const secretName = provider === 'github' ? 'PRODCYCLE_API_KEY' : 'PC_API_KEY';
1056
+ const secretName = 'PRODCYCLE_API_KEY';
961
1057
  return {
962
1058
  status: 'installed',
963
1059
  message: `[ci:${provider}] wrote ${fullPath}. ` +
@@ -1000,8 +1096,8 @@ const GITLAB_WORKFLOW = `# Prodcycle compliance scan. Include from your main .gi
1000
1096
  # include:
1001
1097
  # - local: .gitlab-ci.prodcycle.yml
1002
1098
  #
1003
- # Set PC_API_KEY as a CI/CD variable (Settings → CI/CD → Variables) before
1004
- # the first run. Mark it Masked + Protected.
1099
+ # Set PRODCYCLE_API_KEY as a CI/CD variable (Settings → CI/CD → Variables)
1100
+ # before the first run. Mark it Masked + Protected.
1005
1101
 
1006
1102
  prodcycle:
1007
1103
  stage: test
@@ -1039,8 +1135,8 @@ const CIRCLECI_WORKFLOW = `# Prodcycle compliance scan. To use this, either repl
1039
1135
  # jobs:
1040
1136
  # - prodcycle-scan
1041
1137
  #
1042
- # Set PC_API_KEY as a project environment variable in CircleCI before the
1043
- # first run.
1138
+ # Set PRODCYCLE_API_KEY as a project environment variable in CircleCI before
1139
+ # the first run.
1044
1140
  #
1045
1141
  # CircleCI does not expose the PR target branch as a built-in env var
1046
1142
  # (\`CIRCLE_BASE_BRANCH\` does not exist; see
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ exports.scan = scan;
19
19
  exports.gate = gate;
20
20
  const api_client_1 = require("./api-client");
21
21
  const fs_1 = require("./utils/fs");
22
+ const vcs_1 = require("./utils/vcs");
22
23
  __exportStar(require("./api-client"), exports);
23
24
  __exportStar(require("./formatters/table"), exports);
24
25
  __exportStar(require("./formatters/prompt"), exports);
@@ -56,17 +57,24 @@ async function scan(params) {
56
57
  summary: undefined,
57
58
  };
58
59
  }
60
+ // Compute the local-only files (git-ignored AND untracked) from the real
61
+ // `.git` in this checkout. The hosted API collects files in a sandbox
62
+ // without a real `.git`, so it can't derive this itself — we send it as a
63
+ // top-level `vcsLocalOnly` hint so the scanner downgrades secrets that live
64
+ // only in the local working tree (e.g. a developer's `.env`). Fail-safe:
65
+ // returns [] on any git error / non-repo, in which case nothing changes.
66
+ const vcsLocalOnly = await (0, vcs_1.computeVcsLocalOnly)(repoPath, Object.keys(files));
59
67
  const client = new api_client_1.ComplianceApiClient(options.apiUrl, options.apiKey);
60
68
  const mode = options.config?.mode ?? 'sync';
61
69
  let response;
62
70
  if (mode === 'async') {
63
- response = await client.validateAndPoll(files, frameworks, options);
71
+ response = await client.validateAndPoll(files, frameworks, options, vcsLocalOnly);
64
72
  }
65
73
  else if (mode === 'chunked') {
66
- response = await client.validateChunked(files, frameworks, options);
74
+ response = await client.validateChunked(files, frameworks, options, vcsLocalOnly);
67
75
  }
68
76
  else {
69
- response = await client.validate(files, frameworks, options);
77
+ response = await client.validate(files, frameworks, options, vcsLocalOnly);
70
78
  }
71
79
  // Pull `scannerError` through if the API set it. Picking the field
72
80
  // explicitly (rather than `...response`) so the CLI's public surface
@@ -109,9 +117,15 @@ async function scan(params) {
109
117
  * endpoint, used by coding-agent post-edit hooks).
110
118
  */
111
119
  async function gate(options) {
112
- const { files, frameworks = ['soc2'], ...scanOpts } = options;
113
- const client = new api_client_1.ComplianceApiClient(options.apiUrl, options.apiKey);
114
- const response = await client.hook(files, frameworks, scanOpts);
120
+ const { files, frameworks = ['soc2'], severityThreshold = 'medium', failOn = ['critical', 'high'], config = {}, apiKey, apiUrl, } = options;
121
+ const client = new api_client_1.ComplianceApiClient(apiUrl, apiKey);
122
+ // Forward severityThreshold/failOn/config to the hook endpoint so programmatic
123
+ // callers can influence server-side filtering the same way scan() does — and
124
+ // so Node and Python gate() send identical defaults (medium / [critical,high])
125
+ // for the same input. Applying the defaults here (rather than relying on a
126
+ // rest-spread of caller-supplied keys) keeps the two SDKs in lockstep with the
127
+ // documented contract; see python/src/prodcycle/__init__.py `gate()`.
128
+ const response = await client.hook(files, frameworks, { severityThreshold, failOn, config });
115
129
  // Same scannerError plumbing as scan() above. Coding-agent hooks
116
130
  // especially need to distinguish "code is clean" from "scanner is
117
131
  // down" — agents should NOT proceed on the latter.
@@ -22,7 +22,8 @@ export declare function readConfig(): ProdcycleConfig;
22
22
  export declare function writeApiKey(apiKey: string): string;
23
23
  /**
24
24
  * Resolve the API key, in precedence order: an explicit value (CLI
25
- * `--api-key`), the `PC_API_KEY` env var, then the user-level config file.
25
+ * `--api-key`), the `PRODCYCLE_API_KEY` env var (legacy `PC_API_KEY` still
26
+ * accepted with a deprecation notice), then the user-level config file.
26
27
  * Returns `''` when none is configured.
27
28
  *
28
29
  * Pass an already-read `config` to avoid re-reading the file when the
@@ -30,7 +31,8 @@ export declare function writeApiKey(apiKey: string): string;
30
31
  */
31
32
  export declare function resolveApiKey(explicit?: string, config?: ProdcycleConfig): string;
32
33
  /**
33
- * Resolve the API URL: explicit value → `PC_API_URL` env → config file →
34
+ * Resolve the API URL: explicit value → `PRODCYCLE_API_URL` env (legacy
35
+ * `PC_API_URL` still accepted with a deprecation notice) → config file →
34
36
  * `undefined` (the caller then applies its own default). Accepts an
35
37
  * already-read `config` — see `resolveApiKey`.
36
38
  */
@@ -2,8 +2,8 @@
2
2
  // User-level ProdCycle configuration.
3
3
  //
4
4
  // Lets a developer save their API key once, on disk, instead of exporting
5
- // `PC_API_KEY` into every shell — and, critically, into the environment a
6
- // GUI-launched editor runs in, which does NOT inherit shell exports. The
5
+ // `PRODCYCLE_API_KEY` into every shell — and, critically, into the environment
6
+ // a GUI-launched editor runs in, which does NOT inherit shell exports. The
7
7
  // hook reads this file regardless of how the agent was launched, so
8
8
  // `prodcycle init --api-key pc_...` is a one-time setup with no relaunch.
9
9
  //
@@ -54,6 +54,30 @@ const path = __importStar(require("path"));
54
54
  // One-shot guard so a malformed config file warns at most once per process
55
55
  // (both `resolveApiKey` and `resolveApiUrl` call `readConfig`).
56
56
  let warnedMalformed = false;
57
+ // Track which deprecated env vars we've already warned about, so a long-lived
58
+ // process (e.g. a watch-mode agent hook) warns at most once per variable.
59
+ const warnedDeprecatedEnv = new Set();
60
+ /**
61
+ * Read an env var, preferring `preferred` and falling back to the legacy
62
+ * `deprecated` name. When only the legacy name is set, emit a one-time
63
+ * deprecation notice on stderr so existing users keep working but are nudged
64
+ * to the new name. Returns `undefined` when neither is set.
65
+ *
66
+ * The env vars were renamed `PC_*` → `PRODCYCLE_*` to unify with the
67
+ * `prodcycle/actions` GitHub Action (which already used `PRODCYCLE_API_KEY`).
68
+ */
69
+ function envWithDeprecatedFallback(preferred, deprecated) {
70
+ const current = process.env[preferred];
71
+ if (current)
72
+ return current;
73
+ const legacy = process.env[deprecated];
74
+ if (legacy && !warnedDeprecatedEnv.has(deprecated)) {
75
+ warnedDeprecatedEnv.add(deprecated);
76
+ process.stderr.write(`Warning: ${deprecated} is deprecated and will be removed in a future ` +
77
+ `release; use ${preferred} instead.\n`);
78
+ }
79
+ return legacy;
80
+ }
57
81
  /**
58
82
  * Path to the user-level config file:
59
83
  * `$XDG_CONFIG_HOME/prodcycle/config.json`, falling back to
@@ -110,20 +134,28 @@ function writeApiKey(apiKey) {
110
134
  }
111
135
  /**
112
136
  * Resolve the API key, in precedence order: an explicit value (CLI
113
- * `--api-key`), the `PC_API_KEY` env var, then the user-level config file.
137
+ * `--api-key`), the `PRODCYCLE_API_KEY` env var (legacy `PC_API_KEY` still
138
+ * accepted with a deprecation notice), then the user-level config file.
114
139
  * Returns `''` when none is configured.
115
140
  *
116
141
  * Pass an already-read `config` to avoid re-reading the file when the
117
142
  * caller resolves several values at once (see `ComplianceApiClient`).
118
143
  */
119
144
  function resolveApiKey(explicit, config) {
120
- return explicit || process.env.PC_API_KEY || (config ?? readConfig()).api_key || '';
145
+ return (explicit ||
146
+ envWithDeprecatedFallback('PRODCYCLE_API_KEY', 'PC_API_KEY') ||
147
+ (config ?? readConfig()).api_key ||
148
+ '');
121
149
  }
122
150
  /**
123
- * Resolve the API URL: explicit value → `PC_API_URL` env → config file →
151
+ * Resolve the API URL: explicit value → `PRODCYCLE_API_URL` env (legacy
152
+ * `PC_API_URL` still accepted with a deprecation notice) → config file →
124
153
  * `undefined` (the caller then applies its own default). Accepts an
125
154
  * already-read `config` — see `resolveApiKey`.
126
155
  */
127
156
  function resolveApiUrl(explicit, config) {
128
- return explicit || process.env.PC_API_URL || (config ?? readConfig()).api_url || undefined;
157
+ return (explicit ||
158
+ envWithDeprecatedFallback('PRODCYCLE_API_URL', 'PC_API_URL') ||
159
+ (config ?? readConfig()).api_url ||
160
+ undefined);
129
161
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Repo-relative paths (subset of candidatePaths) that are local-only: git
3
+ * IGNORES them, does NOT track them, AND they were never committed in any
4
+ * reachable history. Async (non-blocking) variant of the engine's
5
+ * `computeVcsLocalOnly` in @prodcycle/compliance-code-scanner; the LOGIC is
6
+ * identical, so keep the two in sync. The hosted API (which collects in a
7
+ * sandbox without a real `.git`) relies on this hint to downgrade secrets in
8
+ * local-only files.
9
+ *
10
+ * Recall-safe by construction:
11
+ * - tracked files (even force-added past `.gitignore`) are filtered out before
12
+ * the ignore check, so a currently-committed secret is never local-only;
13
+ * - a file that was committed then `rm --cached`'d + gitignored is untracked
14
+ * AND ignored, yet still recoverable from `git log --all` — the history
15
+ * check excludes it so its secret stays critical (the real-leak case).
16
+ * Fail-safe: returns [] on any git error / non-repo / shallow clone — a
17
+ * truncated history can't prove a path was never committed, so we downgrade
18
+ * nothing rather than risk masking a leak.
19
+ */
20
+ export declare function computeVcsLocalOnly(repoRoot: string, candidatePaths: string[]): Promise<string[]>;
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.computeVcsLocalOnly = computeVcsLocalOnly;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const GIT_TIMEOUT_MS = 10_000;
11
+ const toPosix = (p) => p.split(node_path_1.default.sep).join('/');
12
+ /**
13
+ * Run `git -C <repoRoot> <args>` WITHOUT blocking the event loop — the CLI runs
14
+ * this mid-scan alongside async HTTP, and `git ls-files` on a large repo can
15
+ * take seconds. Optional `input` is written to stdin (for `check-ignore
16
+ * --stdin`). Resolves with stdout + exit code; rejects only when git cannot be
17
+ * spawned (e.g. not installed).
18
+ */
19
+ function runGit(repoRoot, args, input) {
20
+ return new Promise((resolve, reject) => {
21
+ const child = (0, node_child_process_1.spawn)('git', ['-C', repoRoot, ...args], {
22
+ stdio: ['pipe', 'pipe', 'ignore'],
23
+ timeout: GIT_TIMEOUT_MS,
24
+ });
25
+ let stdout = '';
26
+ child.stdout.setEncoding('utf-8');
27
+ child.stdout.on('data', (chunk) => {
28
+ stdout += chunk;
29
+ });
30
+ child.once('error', reject); // git missing / spawn failure
31
+ child.once('close', (code) => resolve({ stdout, code: code ?? 1 }));
32
+ if (input !== undefined)
33
+ child.stdin.write(input);
34
+ child.stdin.end();
35
+ });
36
+ }
37
+ /**
38
+ * Repo-relative paths (subset of candidatePaths) that are local-only: git
39
+ * IGNORES them, does NOT track them, AND they were never committed in any
40
+ * reachable history. Async (non-blocking) variant of the engine's
41
+ * `computeVcsLocalOnly` in @prodcycle/compliance-code-scanner; the LOGIC is
42
+ * identical, so keep the two in sync. The hosted API (which collects in a
43
+ * sandbox without a real `.git`) relies on this hint to downgrade secrets in
44
+ * local-only files.
45
+ *
46
+ * Recall-safe by construction:
47
+ * - tracked files (even force-added past `.gitignore`) are filtered out before
48
+ * the ignore check, so a currently-committed secret is never local-only;
49
+ * - a file that was committed then `rm --cached`'d + gitignored is untracked
50
+ * AND ignored, yet still recoverable from `git log --all` — the history
51
+ * check excludes it so its secret stays critical (the real-leak case).
52
+ * Fail-safe: returns [] on any git error / non-repo / shallow clone — a
53
+ * truncated history can't prove a path was never committed, so we downgrade
54
+ * nothing rather than risk masking a leak.
55
+ */
56
+ async function computeVcsLocalOnly(repoRoot, candidatePaths) {
57
+ if (candidatePaths.length === 0)
58
+ return [];
59
+ const root = node_path_1.default.resolve(repoRoot);
60
+ let tracked;
61
+ try {
62
+ const { stdout, code } = await runGit(root, ['ls-files', '-z']);
63
+ if (code !== 0)
64
+ return []; // not a git work tree / git error → fail safe
65
+ tracked = new Set(stdout.split('\0').filter(Boolean));
66
+ }
67
+ catch {
68
+ return []; // git not available → fail safe
69
+ }
70
+ const candidates = [...new Set(candidatePaths.map(toPosix))].filter((p) => !tracked.has(p));
71
+ if (candidates.length === 0)
72
+ return [];
73
+ let ignoredUntracked;
74
+ try {
75
+ // check-ignore exits 0 when ≥1 path is ignored, 1 when none (stdout empty),
76
+ // 128 on error. Treat anything other than 0/1 as a failure → fail safe.
77
+ const { stdout, code } = await runGit(root, ['check-ignore', '--stdin', '-z'], candidates.join('\0'));
78
+ if (code !== 0 && code !== 1)
79
+ return [];
80
+ const ignored = new Set(stdout.split('\0').filter(Boolean));
81
+ ignoredUntracked = candidates.filter((p) => ignored.has(p));
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ if (ignoredUntracked.length === 0)
87
+ return [];
88
+ // Truncated history can't disprove a past commit → keep everything critical.
89
+ // Detect shallowness via the marker file located with `--git-path shallow`
90
+ // (git ≥ 2.5) rather than `--is-shallow-repository` (git ≥ 2.15), so the
91
+ // downgrade feature isn't silently disabled on older git toolchains.
92
+ try {
93
+ const { stdout, code } = await runGit(root, ['rev-parse', '--git-path', 'shallow']);
94
+ if (code !== 0)
95
+ return []; // old/unsupported git → fail safe
96
+ const marker = stdout.trim();
97
+ if (!marker)
98
+ return [];
99
+ const abs = node_path_1.default.isAbsolute(marker) ? marker : node_path_1.default.join(root, marker);
100
+ if ((0, node_fs_1.existsSync)(abs))
101
+ return []; // shallow clone → downgrade nothing
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ // Exclude any candidate that appears in committed history (every branch, tag,
107
+ // remote-tracking ref). `--all` walks all refs; `--diff-filter=A` emits only
108
+ // ADDED entries so a wide commit contributes just the candidate's
109
+ // introduction (proving it was once in history) while keeping output small.
110
+ // `-m` decomposes merge commits into per-parent diffs — without it, a merge's
111
+ // COMBINED diff omits a file added on only one side of a true 2-parent merge
112
+ // (a recall hole); `-m` surfaces it as "A" against the mainline parent.
113
+ // `--no-renames` keeps names literal so a renamed-away path still matches its
114
+ // old name. The `-- <paths>` pathspec restricts the walk to our candidates.
115
+ try {
116
+ const { stdout, code } = await runGit(root, [
117
+ 'log',
118
+ '--all',
119
+ '-m',
120
+ '--no-renames',
121
+ '--diff-filter=A',
122
+ '--format=',
123
+ '--name-only',
124
+ '-z',
125
+ '--',
126
+ ...ignoredUntracked,
127
+ ]);
128
+ if (code !== 0)
129
+ return []; // fail safe: assume in history
130
+ const candSet = new Set(ignoredUntracked);
131
+ // -z separates with NUL; an empty --format still emits stray newlines. Split
132
+ // on both and keep only candidate tokens (membership is all we need).
133
+ const everCommitted = new Set(stdout.split(/[\0\n]/).filter((t) => candSet.has(t)));
134
+ return ignoredUntracked.filter((p) => !everCommitted.has(p));
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "description": "Multi-framework policy-as-code compliance scanner for infrastructure and application code.",
5
5
  "homepage": "https://docs.prodcycle.com",
6
6
  "repository": {
@@ -32,7 +32,10 @@
32
32
  "security"
33
33
  ],
34
34
  "author": "ProdCycle, Inc. <engineering@prodcycle.com>",
35
- "license": "SEE LICENSE IN LICENSE",
35
+ "license": "Apache-2.0",
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
36
39
  "dependencies": {
37
40
  "commander": "^12.0.0",
38
41
  "minimatch": "^9.0.3"