pi-warden 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ How to keep this current: add the entry in the same pull request as the change,
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 1.7.0
10
+
11
+ ### Added
12
+
13
+ - `security.shareLinkParams` names the query parameters whose value is a share link rather than a credential, so a link the user reuses (a RED post's `xsec_token`) stays openable in the tool result instead of coming back as a dead `[redacted]` URL. Empty by default, user config only, and matched as a whole parameter name, with its case. A credential by construction is still masked inside a listed parameter, and a value that also appears outside the URL is masked everywhere. ([Configuration](docs/configuration.md#user-config))
14
+
9
15
  ## 1.6.0
10
16
 
11
17
  ### Added
package/dist/config.d.ts CHANGED
@@ -202,6 +202,8 @@ export interface SecurityConfig {
202
202
  threshold: number;
203
203
  /** Replace high-confidence credential values in tool results with `[redacted]` before the model sees them. */
204
204
  maskOutput: boolean;
205
+ /** Query parameters whose value is a share link rather than a credential. Empty by default; user config only. */
206
+ shareLinkParams: string[];
205
207
  }
206
208
  export interface RulesConfig {
207
209
  /** Judge each write and edit against the project's Markdown rules on its own Jev request; steer, never hold. */
package/dist/config.js CHANGED
@@ -70,7 +70,7 @@ export function defaultConfig() {
70
70
  done: { enabled: true, claimsDone: 0.7, nudge: true, uiProof: true, uiFiles: [...DEFAULT_UI_FILES], visualTools: defaultVisualTools() },
71
71
  cause: { enabled: true, claimsCause: 0.7, nudge: true, windowDays: 7 },
72
72
  slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
73
- security: { enabled: true, threshold: 0.7, maskOutput: true },
73
+ security: { enabled: true, threshold: 0.7, maskOutput: true, shareLinkParams: [] },
74
74
  rules: { enabled: true, threshold: 0.7, softThreshold: 0, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
75
75
  // 0.3 is the measured cut: 69% of the rules named apply, and 68 of the 73 requests that touch a rule's area get one.
76
76
  rulesAtTurnStart: { enabled: true, threshold: 0.3 },
@@ -580,6 +580,8 @@ function applyGuards(base, raw, timeoutMs, source, warnings) {
580
580
  threshold: source === "project" ? projectProbability("security.threshold", raw.security.threshold, base.security.threshold, warnings) : probability(raw.security.threshold, base.security.threshold),
581
581
  // Only the user file can turn masking off: a repository must not unmask credentials in its own agent's output.
582
582
  maskOutput: source === "user" ? boolean(raw.security.maskOutput, base.security.maskOutput) : base.security.maskOutput,
583
+ // A listed parameter excuses a share-link token, so it is user config only for the same reason as `maskOutput`.
584
+ shareLinkParams: source === "user" ? globList(raw.security.shareLinkParams, base.security.shareLinkParams) : base.security.shareLinkParams,
583
585
  } : base.security,
584
586
  context: isObject(raw.context) ? {
585
587
  enabled: boolean(raw.context.enabled, base.context.enabled),
package/dist/extension.js CHANGED
@@ -2394,7 +2394,7 @@ export default function wardenExtension(host) {
2394
2394
  // and sends nothing, so it runs with the security guard off; only the user's `security.maskOutput` stops it.
2395
2395
  const rawTexts = event.content.filter(part => part.type === "text").map(part => part.text ?? "");
2396
2396
  const maskOutput = config.security.maskOutput;
2397
- const masking = maskOutput ? rawTexts.map(maskSecrets) : [];
2397
+ const masking = maskOutput ? rawTexts.map((blockText) => maskSecrets(blockText, config.security.shareLinkParams)) : [];
2398
2398
  const maskedCount = masking.reduce((sum, block) => sum + block.masked, 0);
2399
2399
  let maskIndex = 0;
2400
2400
  const maskedContent = maskedCount ? event.content.map(part => part.type === "text" ? { ...part, text: masking[maskIndex++].text } : part) : event.content;
package/dist/output.js CHANGED
@@ -114,7 +114,7 @@ export function buildOutputRequest(tool, text, task, security, compress, context
114
114
  }
115
115
  export async function evaluateOutput(tool, text, task, options, compressionLearner) {
116
116
  const secrets = options.security.enabled ? findSecrets(text) : [];
117
- const { real, synthetic } = partitionSecrets(secrets, text);
117
+ const { real, synthetic } = partitionSecrets(secrets, text, options.security.shareLinkParams);
118
118
  const verdict = { secret: real.length > 0, suspicious: false, retention: "all" };
119
119
  if (real.length) {
120
120
  verdict.secretId = secretFingerprint(real);
package/dist/redact.d.ts CHANGED
@@ -16,9 +16,11 @@ export declare function looksLikeSecretValue(value: string): boolean;
16
16
  export declare function syntheticish(value: string): boolean;
17
17
  /**
18
18
  * Split credentials into the ones worth announcing and the stand-ins that are only traced. With `text`, a value found
19
- * only in the query signature of a signed URL is a stand-in too.
19
+ * only in the query signature of a signed URL is a stand-in too, as is a value found only in a share-link parameter
20
+ * named by `shareLinkParams`. A credential by construction stays real even inside a listed parameter, so a real key
21
+ * that a tool result puts in a listed parameter is still masked.
20
22
  */
21
- export declare function partitionSecrets(secrets: readonly string[], text?: string): {
23
+ export declare function partitionSecrets(secrets: readonly string[], text?: string, shareLinkParams?: readonly string[]): {
22
24
  real: string[];
23
25
  synthetic: string[];
24
26
  };
@@ -29,7 +31,7 @@ export declare function findSecrets(text: string): string[];
29
31
  * assignments, `Authorization` and `Bearer` values, and URL passwords. Stand-ins (`syntheticish`, signed URL
30
32
  * signatures) stay readable. Masking the whole detected set means a value is never announced without being masked.
31
33
  */
32
- export declare function maskSecrets(text: string): {
34
+ export declare function maskSecrets(text: string, shareLinkParams?: readonly string[]): {
33
35
  text: string;
34
36
  masked: number;
35
37
  };
package/dist/redact.js CHANGED
@@ -181,11 +181,40 @@ function signedUrlSpans(text) {
181
181
  }
182
182
  return spans;
183
183
  }
184
+ /** A literal for a `RegExp`, for the parameter names that come from the user's config. */
185
+ function escapeRegExp(text) {
186
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
+ }
184
188
  /**
185
- * True when every occurrence of `value` in `text` sits inside the query signature of a signed URL. Such a URL expires
186
- * within minutes and grants one object; issue-tracker and storage API responses are full of them.
189
+ * Start and end offsets of the value of every listed share-link parameter in an `https://` URL in `text`. A share link
190
+ * carries a token that is what makes the link openable at all, and the same link is reused rather than expiring, so
191
+ * its token is a stand-in like a signed URL's signature: `security.shareLinkParams` names the parameters to read.
187
192
  */
188
- function signedUrlOnly(value, spans, text) {
193
+ function shareLinkSpans(text, shareLinkParams) {
194
+ const names = [...new Set(shareLinkParams.map((name) => name.trim()).filter(Boolean))];
195
+ if (!names.length)
196
+ return [];
197
+ // The value stops at a backslash because a JSON body writes `&` as `\u0026`, and the name is matched with its case.
198
+ const pattern = new RegExp(`[?&](?:${names.map(escapeRegExp).join("|")})=([^&#\\\\]*)`, "g");
199
+ const spans = [];
200
+ for (const url of text.matchAll(HTTPS_URL)) {
201
+ for (const param of url[0].matchAll(pattern)) {
202
+ const end = url.index + param.index + param[0].length;
203
+ spans.push([end - param[1].length, end]);
204
+ }
205
+ }
206
+ return spans;
207
+ }
208
+ /** True when the value is a credential by construction, so a listed share-link parameter must not excuse it. */
209
+ function isTokenShape(value) {
210
+ return TOKEN_SHAPES.some((shape) => new RegExp(shape.source).test(value));
211
+ }
212
+ /**
213
+ * True when every occurrence of `value` in `text` sits inside one of `spans`. A signed URL expires within minutes and
214
+ * grants one object; issue-tracker and storage API responses are full of them. A listed share-link token is excused
215
+ * on the same terms: a value that also appears outside the URL is a credential wherever it is, and is masked there.
216
+ */
217
+ function onlyInsideSpans(value, spans, text) {
189
218
  if (!spans.length)
190
219
  return false;
191
220
  let found = false;
@@ -198,14 +227,20 @@ function signedUrlOnly(value, spans, text) {
198
227
  }
199
228
  /**
200
229
  * Split credentials into the ones worth announcing and the stand-ins that are only traced. With `text`, a value found
201
- * only in the query signature of a signed URL is a stand-in too.
230
+ * only in the query signature of a signed URL is a stand-in too, as is a value found only in a share-link parameter
231
+ * named by `shareLinkParams`. A credential by construction stays real even inside a listed parameter, so a real key
232
+ * that a tool result puts in a listed parameter is still masked.
202
233
  */
203
- export function partitionSecrets(secrets, text) {
234
+ export function partitionSecrets(secrets, text, shareLinkParams = []) {
204
235
  const real = [];
205
236
  const synthetic = [];
206
- const spans = text === undefined ? [] : signedUrlSpans(text);
207
- for (const value of secrets)
208
- (syntheticish(value) || signedUrlOnly(value, spans, text ?? "") ? synthetic : real).push(value);
237
+ const signed = text === undefined ? [] : signedUrlSpans(text);
238
+ const shared = text === undefined ? [] : shareLinkSpans(text, shareLinkParams);
239
+ for (const value of secrets) {
240
+ // The span test first, so the default (no listed parameter) costs nothing.
241
+ const standIn = syntheticish(value) || onlyInsideSpans(value, signed, text ?? "") || (onlyInsideSpans(value, shared, text ?? "") && !isTokenShape(value));
242
+ (standIn ? synthetic : real).push(value);
243
+ }
209
244
  return { real, synthetic };
210
245
  }
211
246
  /** The credential-shaped values in `text`, deduplicated. Empty when the text only talks about credentials. */
@@ -225,8 +260,8 @@ export function findSecrets(text) {
225
260
  * assignments, `Authorization` and `Bearer` values, and URL passwords. Stand-ins (`syntheticish`, signed URL
226
261
  * signatures) stay readable. Masking the whole detected set means a value is never announced without being masked.
227
262
  */
228
- export function maskSecrets(text) {
229
- const { real } = partitionSecrets(findSecrets(text), text);
263
+ export function maskSecrets(text, shareLinkParams = []) {
264
+ const { real } = partitionSecrets(findSecrets(text), text, shareLinkParams);
230
265
  let out = text;
231
266
  let masked = 0;
232
267
  // Longest first, so a value that contains another is replaced whole.
package/dist/shape.js CHANGED
@@ -44,7 +44,7 @@ export function completeConfig(loaded) {
44
44
  done: section("done", { ...off, claimsDone: 1, nudge: false, uiProof: false, uiFiles: [], visualTools: { commands: [], commandWords: [], tools: [], images: [] } }),
45
45
  cause: section("cause", { ...off, claimsCause: 1, nudge: false, windowDays: 7 }),
46
46
  slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
47
- security: section("security", { ...off, threshold: 1, maskOutput: false }),
47
+ security: section("security", { ...off, threshold: 1, maskOutput: false, shareLinkParams: [] }),
48
48
  rules: section("rules", { ...off, threshold: 1, softThreshold: 0, files: [], fallback: false, maxChars: 500, exclude: [], skip: [], sensitivePaths: {} }),
49
49
  // A missing section turns the turn-start reminder off: it is new, so nothing it says was expected.
50
50
  rulesAtTurnStart: section("rulesAtTurnStart", { ...off, threshold: 1 }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
5
5
  "type": "module",
6
6
  "license": "MIT",