@erichll/pi-auto-review 0.3.5 → 0.4.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
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-08-19
4
+
5
+ - **Credential-exfiltration hardening.** Expand deterministic terminal denies to cover
6
+ `.env` variants, common credential files, additional file readers and encoders,
7
+ shell substitutions, staged variables, redirects, and multi-stage pipelines that
8
+ upload credentials to network sinks. Template files such as `.env.example` and
9
+ `.env.sample` remain reviewable.
10
+ - **Reviewer refresh safety.** Re-resolve reviewer model/provider metadata for each
11
+ review, reacquire authentication for each attempt, and bind the reviewer session
12
+ identity to the endpoint and authentication fingerprint so provider or OAuth
13
+ refreshes cannot reuse a stale connection.
14
+ - **Compatibility.** Require Pi 0.84.1+ and permission-system 25.1.0+ to match the
15
+ current reviewer and forwarded-permission contracts.
16
+
3
17
  ## 0.3.5 - 2026-08-13
4
18
 
5
19
  - Raise the default model-review `timeoutMs` from 45s to 90s and the default
package/README.md CHANGED
@@ -106,9 +106,9 @@ Package-shipped defaults live in `src/config.json`:
106
106
  {
107
107
  "model": "codex-auto-review",
108
108
  "reasoning": "low",
109
- "timeoutMs": 45000,
109
+ "timeoutMs": 90000,
110
110
  "maxTokens": 1600,
111
- "retries": 1,
111
+ "retries": 2,
112
112
  "maxUserTranscriptTokens": 1200,
113
113
  "maxToolTranscriptTokens": 1200,
114
114
  "maxRelevantResultTokens": 800,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Model-backed boundary approval broker for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,15 +47,15 @@
47
47
  "test": "node --disable-warning=ExperimentalWarning --experimental-loader ./test/typescript-loader.mjs --test test/*.test.ts"
48
48
  },
49
49
  "peerDependencies": {
50
- "@earendil-works/pi-ai": ">=0.82.1",
51
- "@earendil-works/pi-coding-agent": ">=0.82.1",
52
- "@gotgenes/pi-permission-system": ">=23.0.0"
50
+ "@earendil-works/pi-ai": "^0.84.1",
51
+ "@earendil-works/pi-coding-agent": "^0.84.1",
52
+ "@gotgenes/pi-permission-system": "^25.1.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@earendil-works/pi-ai": "0.83.0",
56
- "@earendil-works/pi-coding-agent": "0.83.0",
57
- "@gotgenes/pi-permission-system": "24.0.0",
58
- "@types/node": "^22.15.3",
55
+ "@earendil-works/pi-ai": "^0.84.1",
56
+ "@earendil-works/pi-coding-agent": "^0.84.1",
57
+ "@gotgenes/pi-permission-system": "^25.1.0",
58
+ "@types/node": "^26.2.0",
59
59
  "typescript": "^6.0.3"
60
60
  }
61
61
  }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { appendFileSync, readFileSync, realpathSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -93,13 +94,24 @@ type ReviewerRuntime = {
93
94
  model: Parameters<typeof completeSimple>[0];
94
95
  auth: {
95
96
  apiKey?: string;
96
- headers?: Record<string, string>;
97
+ // ProviderHeaders allows null values (e.g. "clear" directives) in 0.84.x.
98
+ headers?: Record<string, string | null>;
97
99
  env?: Record<string, string>;
98
100
  };
99
101
  streamSimple?: ApiStreamSimpleFunction;
100
102
  sessionId: string;
101
103
  };
102
104
 
105
+ // Model/stream metadata is re-resolved for every review instead of being
106
+ // cached for the session: pi can refresh models.json or re-register a
107
+ // provider while a session is live, and the resolved model's baseUrl, api
108
+ // or streamSimple may have changed, so a per-session cache could keep
109
+ // calling a stale endpoint. Authentication is also deliberately excluded
110
+ // because pi resolves models.json auth and headers dynamically on every
111
+ // request (including OAuth refresh); it is reacquired per model call in
112
+ // complete().
113
+ type ReviewerMeta = Omit<ReviewerRuntime, "auth" | "sessionId">;
114
+
103
115
  type ReviewResult = {
104
116
  decision: ModelDecision;
105
117
  attempts: number;
@@ -644,10 +656,50 @@ function parseModelRef(modelRef: string): {
644
656
  return { provider, modelId: idParts.join("/") };
645
657
  }
646
658
 
647
- async function resolveReviewer(
659
+ function reviewerSessionId(
660
+ ctx: ExtensionContext,
661
+ config: Config,
662
+ model: ReviewerMeta["model"],
663
+ auth: ReviewerRuntime["auth"],
664
+ ): string {
665
+ // pi-ai reuses Codex WebSockets by session/account without comparing the
666
+ // socket URL or handshake headers. Bind the cache identity to endpoint and
667
+ // authentication metadata so a live provider/auth refresh cannot reuse the
668
+ // old connection. Sensitive values only feed the hash below.
669
+ // Hash the complete identity and keep the result below pi-ai's 64-character
670
+ // prompt-cache-key limit, which would otherwise be able to truncate away a
671
+ // distinguishing suffix on long session IDs or base URLs.
672
+ const identity = JSON.stringify({
673
+ sessionId: ctx.sessionManager.getSessionId(),
674
+ modelRef: config.model,
675
+ provider: model.provider,
676
+ modelId: model.id,
677
+ api: model.api,
678
+ baseUrl: model.baseUrl,
679
+ modelHeaders: Object.entries(model.headers ?? {}).sort(([a], [b]) =>
680
+ a.localeCompare(b),
681
+ ),
682
+ auth: {
683
+ apiKey: auth.apiKey,
684
+ headers: Object.entries(auth.headers ?? {}).sort(([a], [b]) =>
685
+ a.localeCompare(b),
686
+ ),
687
+ env: Object.entries(auth.env ?? {}).sort(([a], [b]) =>
688
+ a.localeCompare(b),
689
+ ),
690
+ },
691
+ });
692
+ const fingerprint = createHash("sha256")
693
+ .update(identity)
694
+ .digest("hex")
695
+ .slice(0, 48);
696
+ return `pi-auto-review-${fingerprint}`;
697
+ }
698
+
699
+ async function resolveReviewerMeta(
648
700
  ctx: ExtensionContext,
649
701
  config: Config,
650
- ): Promise<ReviewerRuntime> {
702
+ ): Promise<ReviewerMeta> {
651
703
  const { provider, modelId } = parseModelRef(config.model);
652
704
  const available = ctx.modelRegistry.getAvailable();
653
705
  const registeredModel = provider
@@ -671,8 +723,6 @@ async function resolveReviewer(
671
723
  : `model ${config.model} is unavailable`,
672
724
  );
673
725
  }
674
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
675
- if (!auth.ok) throw new Error(`model authentication failed: ${auth.error}`);
676
726
 
677
727
  const registered = (
678
728
  ctx.modelRegistry as ExtensionContext["modelRegistry"] & {
@@ -689,13 +739,24 @@ async function resolveReviewer(
689
739
 
690
740
  return {
691
741
  model,
692
- auth,
693
742
  streamSimple:
694
743
  registered?.api === model.api ? registered.streamSimple : undefined,
695
- sessionId: `${ctx.sessionManager.getSessionId()}-codex-auto-review`,
696
744
  };
697
745
  }
698
746
 
747
+ // pi resolves models.json auth and headers dynamically on every request;
748
+ // reacquire authentication per model call instead of pinning it for the
749
+ // session (rotating OAuth tokens would otherwise go stale and fail closed
750
+ // until session restart).
751
+ async function resolveApiKeyAndHeaders(
752
+ ctx: ExtensionContext,
753
+ model: ReviewerMeta["model"],
754
+ ): Promise<ReviewerRuntime["auth"]> {
755
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
756
+ if (!auth.ok) throw new Error(`model authentication failed: ${auth.error}`);
757
+ return auth;
758
+ }
759
+
699
760
  async function modelCall(
700
761
  runtime: ReviewerRuntime,
701
762
  config: Config,
@@ -745,11 +806,17 @@ async function modelCall(
745
806
  return text;
746
807
  }
747
808
 
809
+ type ReviewerResolver = (
810
+ ctx: ExtensionContext,
811
+ config: Config,
812
+ ) => Promise<ReviewerMeta>;
813
+
748
814
  async function complete(
749
815
  ctx: ExtensionContext,
750
816
  config: Config,
751
817
  request: BoundaryRequest,
752
818
  reviewerContext?: BoundaryReviewerContext,
819
+ resolve: ReviewerResolver = resolveReviewerMeta,
753
820
  ): Promise<ReviewResult> {
754
821
  const started = Date.now();
755
822
  const transcript = buildClassifierTranscript(
@@ -772,11 +839,17 @@ async function complete(
772
839
  if (controller.signal.aborted) {
773
840
  throw new Error("review aborted before model resolution");
774
841
  }
775
- const runtime = await resolveReviewer(ctx, config);
842
+ const meta = await resolve(ctx, config);
776
843
  let lastError: unknown;
777
844
  const retryErrors: string[] = [];
778
845
  for (let attempt = 1; attempt <= config.retries + 1; attempt++) {
779
846
  try {
847
+ const auth = await resolveApiKeyAndHeaders(ctx, meta.model);
848
+ const runtime: ReviewerRuntime = {
849
+ ...meta,
850
+ auth,
851
+ sessionId: reviewerSessionId(ctx, config, meta.model, auth),
852
+ };
780
853
  const decision = parseDecision(
781
854
  await modelCall(
782
855
  runtime,
@@ -885,6 +958,9 @@ export function createPiAutoReviewExtension(
885
958
  let disposeBrokerService: (() => void) | undefined;
886
959
  const reviewResults = new Map<string, ReviewResult>();
887
960
  let broker: BoundaryApprovalBroker | undefined;
961
+ // Reviewer metadata is re-resolved per review (see ReviewerMeta above),
962
+ // so a models.json or provider refresh mid-session is observed on the
963
+ // next review instead of reusing a stale model/stream binding.
888
964
  const uiAutoConfirmer = new PermissionUiAutoConfirmer(
889
965
  () => config.autoConfirmBoundedAllows,
890
966
  );
@@ -898,6 +974,7 @@ export function createPiAutoReviewExtension(
898
974
  config,
899
975
  request,
900
976
  reviewerContext,
977
+ resolveReviewerMeta,
901
978
  );
902
979
  if (request.source === "permission-system") {
903
980
  reviewResults.set(request.id, result);
package/src/policy.ts CHANGED
@@ -298,15 +298,139 @@ export function deterministicHardDeny(
298
298
  };
299
299
  }
300
300
 
301
- const credentialSource =
302
- /(?:\/|~\/|\$HOME\/|\$\{HOME\}\/)(?:\.ssh\/(?:id_[A-Za-z0-9_-]+|authorized_keys)|\.aws\/credentials|\.kube\/config|\.docker\/config\.json|\.npmrc|\.netrc|\.pi\/agent\/auth\.json)|(?:^|[\/@\s])\.env(?:\s|$)/i;
303
- const networkUpload =
304
- /\b(?:curl\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T)|wget\b[^;\n]*(?:--post-file|--post-data)|(?:nc|ncat|socat)\b)/i;
305
- const credentialPipe =
306
- /(?:cat|sed|awk|base64|openssl)\b[^|;\n]*(?:\.ssh|\.aws|\.kube|\.docker|\.npmrc|\.netrc|\.env|auth\.json)[^|;\n]*\|[^;\n]*\b(?:curl|wget|nc|ncat|socat)\b/i;
301
+ // Secret path text is only evidence when it occurs as the operand of an
302
+ // operation that reads a file. In particular, a slash-delimited `.env` in
303
+ // a URL or literal request payload is not itself a credential source.
304
+ const envFileName =
305
+ String.raw`\.env(?:\.(?!example\b|sample\b)[A-Za-z0-9_-]*)*`;
306
+ const envFile =
307
+ envFileName + String.raw`(?=[\s"'/@<>=|;&)\`]|$)`;
308
+ const envPath =
309
+ String.raw`(?:[^\s"'@<>=|;&()]+\/)*` + envFile;
310
+ const credentialDirectoryPath =
311
+ String.raw`(?:~\/|\$HOME\/|\$\{HOME\}\/|\/|\.\.?\/)?` +
312
+ String.raw`(?:[^\s"'@<>=|;&()\/]+\/)*` +
313
+ String.raw`(?:\.ssh\/(?:id_[A-Za-z0-9_-]+|authorized_keys)|\.aws\/credentials|\.kube\/config|\.docker\/config\.json|\.npmrc|\.netrc|\.pi\/agent\/auth\.json)`;
314
+ const credentialPath =
315
+ String.raw`(?:` +
316
+ credentialDirectoryPath +
317
+ String.raw`|` + envPath + String.raw`)`;
318
+ const directCredentialUpload = new RegExp(
319
+ String.raw`\b(?:curl|wget)\b[^;\n]*(?:` +
320
+ String.raw`(?:--data(?:-binary|-urlencode)?|-d|--form|-F)(?:=|\s+)[^;\n\s]*@\s*["']?` + credentialPath +
321
+ String.raw`|(?:--upload-file|--post-file)(?:=|\s+)["']?` + credentialPath +
322
+ String.raw`|-T(?:\s*|=)["']?` + credentialPath +
323
+ String.raw`)["']?`,
324
+ "i",
325
+ );
326
+ const redirectedCredentialUpload = new RegExp(
327
+ String.raw`\b(?:` +
328
+ String.raw`(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*` +
329
+ String.raw`|(?:nc|ncat|socat)\b[^;\n]*` +
330
+ String.raw`)<\s*["']?` +
331
+ credentialPath,
332
+ "i",
333
+ );
334
+ // The pipe matcher covers any content-emitting reader that names a secret
335
+ // file (a bare filename such as `head .env`, `dd if=.env`, or a path like
336
+ // ~/.aws/credentials) and feeds a network sink. The producer set is
337
+ // enumerated rather than derived from directCredentialUpload because a bare
338
+ // filename argument carries no path/@/redirect evidence; keeping the list
339
+ // broad is fail-closed and cheap, since a match also requires a secret-file
340
+ // token before the pipe and a network sink after it. This is best-effort
341
+ // hardening, not a completeness guarantee: readers not on the list still
342
+ // fall through to the contextual matchers below or the dynamic reviewer.
343
+ // The leading lookbehind keeps the producers from
344
+ // matching mid-word (star, code) so the terms stay real tool tokens.
345
+ const credentialPipe = new RegExp(
346
+ String.raw`(?<![A-Za-z0-9_])(?:cat|sed|awk|base64|openssl|head|tail|grep|dd|sort|cut|strings|rev|uniq|fold|od|xxd|hexdump|base32|uuencode|gzip|bzip2|xz|zstd|tar|zip)\b[^|;\n]*(?:\.ssh|\.aws|\.kube|\.docker|\.npmrc|\.netrc|` +
347
+ String.raw`(?:^|[\s"'=/@])` +
348
+ envFile +
349
+ String.raw`|auth\.json)[^|;\n]*\|[^;\n]*\b(?:curl|wget|nc|ncat|socat)\b`,
350
+ "i",
351
+ );
352
+ const credentialReader =
353
+ String.raw`(?<![A-Za-z0-9_])(?:cat|sed|awk|base64|openssl|head|tail|grep|dd|sort|cut|strings|rev|uniq|fold|od|xxd|hexdump|base32|uuencode|gzip|bzip2|xz|zstd|tar|zip)\b[^|;\n]*` +
354
+ credentialPath;
355
+ const credentialShellExpansion =
356
+ String.raw`(?:\$\([^)]*` +
357
+ credentialReader +
358
+ String.raw`[^)]*\)|<\([^)]*` +
359
+ credentialReader +
360
+ String.raw`[^)]*\)|\`[^\`\n]*` +
361
+ credentialReader +
362
+ String.raw`[^\`\n]*\`)`;
363
+ const credentialExpansionUpload = new RegExp(
364
+ String.raw`\b(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*` +
365
+ credentialShellExpansion,
366
+ "i",
367
+ );
368
+ const redirectedCredentialExpansion = new RegExp(
369
+ String.raw`\b(?:nc|ncat|socat)\b[^;\n]*(?:<<<|<)\s*` +
370
+ credentialShellExpansion,
371
+ "i",
372
+ );
373
+ const stagedCredentialVariableRead = new RegExp(
374
+ String.raw`\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*` +
375
+ credentialShellExpansion,
376
+ "gi",
377
+ );
378
+ let stagedCredentialVariableUpload = false;
379
+ for (const match of command.matchAll(stagedCredentialVariableRead)) {
380
+ const variable = match[1];
381
+ const suffix = command.slice((match.index ?? 0) + match[0].length);
382
+ const escapedVariable = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
383
+ const variableUpload = new RegExp(
384
+ String.raw`(?:\b(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*|\b(?:nc|ncat|socat)\b[^;\n]*<<<\s*["']?)` +
385
+ String.raw`(?:\$\{` +
386
+ escapedVariable +
387
+ String.raw`\}|\$` +
388
+ escapedVariable +
389
+ String.raw`(?![A-Za-z0-9_]))`,
390
+ "i",
391
+ );
392
+ if (variableUpload.test(suffix)) {
393
+ stagedCredentialVariableUpload = true;
394
+ break;
395
+ }
396
+ }
397
+ const credentialSubstitution = new RegExp(
398
+ String.raw`\b(?:nc|ncat|socat)\b[^;\n]*\$\([^)]*` +
399
+ credentialReader +
400
+ String.raw`[^)]*\)`,
401
+ "i",
402
+ );
403
+ const stagedCredentialRead = new RegExp(
404
+ credentialReader + String.raw`[^|;\n]*>\s*["']?([^\s"';&|]+)["']?`,
405
+ "gi",
406
+ );
407
+ let stagedCredentialUpload = false;
408
+ for (const match of command.matchAll(stagedCredentialRead)) {
409
+ const target = match[1];
410
+ const suffix = command.slice((match.index ?? 0) + match[0].length);
411
+ const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
412
+ const targetUpload = new RegExp(
413
+ String.raw`\b(?:curl|wget)\b[^;\n]*(?:` +
414
+ String.raw`(?:--data(?:-binary|-urlencode)?|-d|--form|-F)(?:=|\s+)[^;\n\s]*@\s*["']?` + escapedTarget +
415
+ String.raw`|(?:--upload-file|--post-file)(?:=|\s+)["']?` + escapedTarget +
416
+ String.raw`|-T(?:\s*|=)["']?` + escapedTarget +
417
+ String.raw`)(?=[\s"']|$)`,
418
+ "i",
419
+ );
420
+ if (targetUpload.test(suffix)) {
421
+ stagedCredentialUpload = true;
422
+ break;
423
+ }
424
+ }
307
425
  if (
308
- (credentialSource.test(command) && networkUpload.test(command)) ||
309
- credentialPipe.test(command)
426
+ directCredentialUpload.test(command) ||
427
+ redirectedCredentialUpload.test(command) ||
428
+ credentialPipe.test(command) ||
429
+ credentialExpansionUpload.test(command) ||
430
+ redirectedCredentialExpansion.test(command) ||
431
+ credentialSubstitution.test(command) ||
432
+ stagedCredentialVariableUpload ||
433
+ stagedCredentialUpload
310
434
  ) {
311
435
  return {
312
436
  rule: "credential-exfiltration",