From 066d6ec2a26de272640c866be3ac257ef4a46f82 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Sat, 18 Jul 2026 18:15:31 +0100 Subject: [PATCH 1/2] chore: update Laravel AI dependency version to allow any version --- composer.json | 2 +- src/InjectionGuard/composer.json | 12 ++++++++++-- src/PIIRedactor/composer.json | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 5c240eb..fbe6162 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ ], "require": { "php": "^8.4.0", - "laravel/ai": "^0.8.0" + "laravel/ai": "*" }, "require-dev": { "laravel/pint": "^1.29.1", diff --git a/src/InjectionGuard/composer.json b/src/InjectionGuard/composer.json index 59f1d69..8ff397b 100644 --- a/src/InjectionGuard/composer.json +++ b/src/InjectionGuard/composer.json @@ -5,10 +5,18 @@ "homepage": "https://intercept.promptphp.com", "type": "library", "license": "MIT", + "authors": [ + { + "name": "Victor Ukam", + "email": "victorjohnukam@gmail.com", + "homepage": "https://github.com/veeqtoh", + "role": "Developer" + } + ], "require": { "php": "^8.4.0", - "laravel/ai": "^0.8.0", - "promptphp/intercept-support": "^0.1" + "laravel/ai": "*", + "promptphp/intercept-support": "*" }, "autoload": { "psr-4": { diff --git a/src/PIIRedactor/composer.json b/src/PIIRedactor/composer.json index c3716e8..49d9c8e 100644 --- a/src/PIIRedactor/composer.json +++ b/src/PIIRedactor/composer.json @@ -15,8 +15,8 @@ ], "require": { "php": "^8.4", - "laravel/ai": "^0.8.0", - "promptphp/intercept-support": "^0.1" + "laravel/ai": "*", + "promptphp/intercept-support": "*" }, "autoload": { "psr-4": { From d52273842b89ecaa65c974eedc780fbacd08d439 Mon Sep 17 00:00:00 2001 From: Victor Ukam Date: Sat, 18 Jul 2026 19:38:57 +0100 Subject: [PATCH 2/2] feat: enhance URL detection in PII Redaction with new patterns and validation --- .../PromptInjectionGuardTestProvider.php | 66 +++++- .../src/Detectors/RegexDetector.php | 34 ++- src/PIIRedactor/src/PIIRedactor.php | 43 +++- .../Fixtures/PIIRedactorTestProvider.php | 66 +++++- src/PIIRedactor/tests/PIIRedactorTest.php | 220 ++++++++++++++++++ 5 files changed, 404 insertions(+), 25 deletions(-) diff --git a/src/InjectionGuard/tests/Fixtures/PromptInjectionGuardTestProvider.php b/src/InjectionGuard/tests/Fixtures/PromptInjectionGuardTestProvider.php index 16f00c1..3f93cb9 100644 --- a/src/InjectionGuard/tests/Fixtures/PromptInjectionGuardTestProvider.php +++ b/src/InjectionGuard/tests/Fixtures/PromptInjectionGuardTestProvider.php @@ -4,8 +4,9 @@ namespace PromptPHP\Intercept\InjectionGuard\Tests\Fixtures; -use Laravel\Ai\Contracts\Gateway\TextGateway; +use Laravel\Ai\Contracts\Gateway\StepTextGateway; use Laravel\Ai\Contracts\Providers\TextProvider; +use Laravel\Ai\Gateway\TextGenerationLoop; use Laravel\Ai\Prompts\AgentPrompt; use Laravel\Ai\Responses\AgentResponse; use Laravel\Ai\Responses\StreamableAgentResponse; @@ -13,38 +14,93 @@ final class PromptInjectionGuardTestProvider implements TextProvider { + /** + * {@inheritDoc} + */ public function prompt(AgentPrompt $prompt): AgentResponse { throw new RuntimeException('Not used in this test.'); } + /** + * {@inheritDoc} + */ public function stream(AgentPrompt $prompt): StreamableAgentResponse { throw new RuntimeException('Not used in this test.'); } - public function textGateway(): TextGateway + /** + * {@inheritDoc} + */ + public function useTextGateway(StepTextGateway $gateway): self { - throw new RuntimeException('Not used in this test.'); + return $this; } - public function useTextGateway(TextGateway $gateway): self + /** + * {@inheritDoc} + */ + public function textGenerationLoop(): TextGenerationLoop { - return $this; + throw new RuntimeException('Not used in this test.'); } + /** + * {@inheritDoc} + */ public function defaultTextModel(): string { return 'test-model'; } + /** + * {@inheritDoc} + */ public function cheapestTextModel(): string { return 'test-cheapest-model'; } + /** + * {@inheritDoc} + */ public function smartestTextModel(): string { return 'test-smartest-model'; } + + // ---- Provider interface ---- + + /** + * {@inheritDoc} + */ + public function name(): string + { + return 'test-provider'; + } + + /** + * {@inheritDoc} + */ + public function driver(): string + { + return 'test'; + } + + /** + * {@inheritDoc} + */ + public function providerCredentials(): array + { + return []; + } + + /** + * {@inheritDoc} + */ + public function additionalConfiguration(): array + { + return []; + } } diff --git a/src/PIIRedactor/src/Detectors/RegexDetector.php b/src/PIIRedactor/src/Detectors/RegexDetector.php index 0df3253..6904060 100644 --- a/src/PIIRedactor/src/Detectors/RegexDetector.php +++ b/src/PIIRedactor/src/Detectors/RegexDetector.php @@ -4,6 +4,7 @@ namespace PromptPHP\Intercept\PIIRedactor\Detectors; +use Closure; use InvalidArgumentException; use PromptPHP\Intercept\PIIRedactor\Detectors\Contracts\Detector; use PromptPHP\Intercept\PIIRedactor\ValueObjects\Detection; @@ -13,14 +14,16 @@ /** * Create a new regex detector. * - * @param string $type The detector entity type. - * @param string $pattern The regex pattern to use for detection. - * @param float $confidence The confidence level of the detection (default: 1.0). + * @param string $type The detector entity type. + * @param string $pattern The regex pattern to use for detection. + * @param float $confidence The confidence level of the detection (default: 1.0). + * @param ?Closure $validator A optional closure to validate detected values. */ public function __construct( protected string $type, protected string $pattern, protected float $confidence = 1.0, + protected ?Closure $validator = null, ) { // } @@ -40,7 +43,7 @@ public function type(): string * * @param string $text The text to analyze for sensitive values. * - * @return array + * @return array An array of Detection objects representing the detected sensitive values. */ public function detect(string $text): array { @@ -59,15 +62,24 @@ public function detect(string $text): array return []; } - return array_map( - fn (array $match): Detection => new Detection( + $detections = []; + + foreach ($matches[0] as $match) { + $value = $match[0]; + + if ($this->validator !== null && ! ($this->validator)($value)) { + continue; + } + + $detections[] = new Detection( type: $this->type, - value: $match[0], + value: $value, start: $match[1], - length: strlen($match[0]), + length: strlen($value), confidence: $this->confidence, - ), - $matches[0], - ); + ); + } + + return $detections; } } diff --git a/src/PIIRedactor/src/PIIRedactor.php b/src/PIIRedactor/src/PIIRedactor.php index 7300637..d57f415 100644 --- a/src/PIIRedactor/src/PIIRedactor.php +++ b/src/PIIRedactor/src/PIIRedactor.php @@ -373,19 +373,53 @@ protected function defaultDetectors(): array EntityTypes::MAC_ADDRESS->value, '/\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/' ), + // Pattern 1: URLs with scheme (http:// or https://). new RegexDetector( EntityTypes::URL->value, - '/\b(?:https?:\/\/|www\.)[a-zA-Z0-9+&@#\/%?=~_|!:,.;]*[a-zA-Z0-9+&@#\/%=~_|]|\b(?"{}|\\^`\[\]]+(?value, + '~\b(?"{}|\\^`\[\]]+)?(? $detections Thre list of detections to process. + * @param array $detections The list of detections to process. * - * @return array + * @return array The list of detections with overlaps removed. */ protected function removeOverlaps(array $detections): array { @@ -439,6 +473,7 @@ protected function priority(string $type): int EntityTypes::EMAIL->value => 30, EntityTypes::IP_ADDRESS->value => 20, EntityTypes::PHONE->value => 10, + EntityTypes::URL->value => 8, EntityTypes::MAC_ADDRESS->value => 5, default => 0, }; @@ -570,7 +605,7 @@ protected function maskGeneric(string $value): string * * @param array $detections The list of detections to summarise. * - * @return array + * @return array A summary of detected entities, with entity types as keys and counts as values. */ protected function summariseEntities(array $detections): array { diff --git a/src/PIIRedactor/tests/Fixtures/PIIRedactorTestProvider.php b/src/PIIRedactor/tests/Fixtures/PIIRedactorTestProvider.php index 75c0bc5..112d125 100644 --- a/src/PIIRedactor/tests/Fixtures/PIIRedactorTestProvider.php +++ b/src/PIIRedactor/tests/Fixtures/PIIRedactorTestProvider.php @@ -4,8 +4,9 @@ namespace PromptPHP\Intercept\PIIRedactor\Tests\Fixtures; -use Laravel\Ai\Contracts\Gateway\TextGateway; +use Laravel\Ai\Contracts\Gateway\StepTextGateway; use Laravel\Ai\Contracts\Providers\TextProvider; +use Laravel\Ai\Gateway\TextGenerationLoop; use Laravel\Ai\Prompts\AgentPrompt; use Laravel\Ai\Responses\AgentResponse; use Laravel\Ai\Responses\StreamableAgentResponse; @@ -13,38 +14,93 @@ final class PIIRedactorTestProvider implements TextProvider { + /** + * {@inheritDoc} + */ public function prompt(AgentPrompt $prompt): AgentResponse { throw new RuntimeException('Not used in this test.'); } + /** + * {@inheritDoc} + */ public function stream(AgentPrompt $prompt): StreamableAgentResponse { throw new RuntimeException('Not used in this test.'); } - public function textGateway(): TextGateway + /** + * {@inheritDoc} + */ + public function useTextGateway(StepTextGateway $gateway): self { - throw new RuntimeException('Not used in this test.'); + return $this; } - public function useTextGateway(TextGateway $gateway): self + /** + * {@inheritDoc} + */ + public function textGenerationLoop(): TextGenerationLoop { - return $this; + throw new RuntimeException('Not used in this test.'); } + /** + * {@inheritDoc} + */ public function defaultTextModel(): string { return 'test-model'; } + /** + * {@inheritDoc} + */ public function cheapestTextModel(): string { return 'test-cheapest-model'; } + /** + * {@inheritDoc} + */ public function smartestTextModel(): string { return 'test-smartest-model'; } + + // ---- Provider interface ---- + + /** + * {@inheritDoc} + */ + public function name(): string + { + return 'test-provider'; + } + + /** + * {@inheritDoc} + */ + public function driver(): string + { + return 'test'; + } + + /** + * {@inheritDoc} + */ + public function providerCredentials(): array + { + return []; + } + + /** + * {@inheritDoc} + */ + public function additionalConfiguration(): array + { + return []; + } } diff --git a/src/PIIRedactor/tests/PIIRedactorTest.php b/src/PIIRedactor/tests/PIIRedactorTest.php index 8bbe6c1..550d4d9 100644 --- a/src/PIIRedactor/tests/PIIRedactorTest.php +++ b/src/PIIRedactor/tests/PIIRedactorTest.php @@ -137,6 +137,226 @@ function (AgentPrompt $prompt) use (&$forwardedPrompt): string { expect($forwardedPrompt->prompt)->toBe('Look at [URL_1], check the page [URL_2], visit the site at [URL_3], or [URL_4], or view the [URL_5].'); }); +it('redacts http URLs with fragments', function (): void { + Log::shouldReceive('warning')->once(); + + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('See http://example.com/docs#section-1 for details.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('See [URL_1] for details.'); +}); + +it('redacts URLs with ports', function (): void { + Log::shouldReceive('warning')->once(); + + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('Dev server at http://localhost:8080/api is ready.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('Dev server at [URL_1] is ready.'); +}); + +it('strips trailing punctuation from scheme URLs', function (): void { + Log::shouldReceive('warning')->once(); + + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('Visit https://example.com, it is great.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('Visit [URL_1], it is great.'); +}); + +it('redacts bare domains that start with www.', function (): void { + Log::shouldReceive('warning')->once(); + + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('Go to www.example.com today.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('Go to [URL_1] today.'); +}); + +it('redacts bare domains that include a path', function (): void { + Log::shouldReceive('warning')->once(); + + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('Repo at github.com/org/repo/pull/123.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('Repo at [URL_1].'); +}); + +it('does not redact bare domains in prose without a path or www prefix', function (): void { + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('I love github.com and use it daily.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + // No PII detected → no log, prompt unchanged + expect($forwardedPrompt->prompt)->toBe('I love github.com and use it daily.'); +}); + +it('does not redact bare domains at end of sentence', function (): void { + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('My favourite site is laravel.com.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('My favourite site is laravel.com.'); +}); + +it('does not redact bare domains inside parentheses', function (): void { + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('See docs (example.com) for more.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('See docs (example.com) for more.'); +}); + +it('does not redact malformed scheme URLs without a host', function (): void { + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('Broken link: http://?foo=bar.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('Broken link: http://?foo=bar.'); +}); + +it('does not redact scheme-only fragments', function (): void { + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('Type http:// here.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('Type http:// here.'); +}); + +it('prefers the full scheme URL over an overlapping bare domain', function (): void { + Log::shouldReceive('warning')->once(); + + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('Check https://example.com/path for updates.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + // Must be a single [URL_1] covering the full scheme URL, not two separate redactions + expect($forwardedPrompt->prompt)->toBe('Check [URL_1] for updates.'); +}); + +it('redacts URLs alongside emails and phone numbers', function (): void { + Log::shouldReceive('warning')->once(); + + $redactor = new PIIRedactor; + + $forwardedPrompt = null; + + $redactor->handle( + makePIIRedactorAgentPrompt('Email victor@example.com or visit https://example.com/help or call 07123456789.'), + function (AgentPrompt $prompt) use (&$forwardedPrompt): string { + $forwardedPrompt = $prompt; + + return 'continued'; + }, + ); + + expect($forwardedPrompt->prompt)->toBe('Email [EMAIL_1] or visit [URL_1] or call [PHONE_1].'); +}); + it('blocks credit cards by default', function (): void { Log::shouldReceive('warning')->once();