spurline-dashboard 0.3.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.
- checksums.yaml +7 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/CLAUDE.md +11 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/CLAUDE.md +11 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/CLAUDE.md +11 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/CLAUDE.md +18 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/CLAUDE.md +11 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/CLAUDE.md +12 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/CLAUDE.md +12 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- metadata +218 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
# The cardinal type of the Spurline framework. Every piece of content flowing
|
|
6
|
+
# through the system is a Content object carrying a trust level and source.
|
|
7
|
+
# Raw strings never enter the context pipeline.
|
|
8
|
+
#
|
|
9
|
+
# Content objects are frozen on creation and cannot be mutated.
|
|
10
|
+
class Content
|
|
11
|
+
TRUST_LEVELS = %i[system operator user external untrusted].freeze
|
|
12
|
+
|
|
13
|
+
TAINTED_LEVELS = %i[external untrusted].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :text, :trust, :source
|
|
16
|
+
|
|
17
|
+
def initialize(text:, trust:, source:)
|
|
18
|
+
validate_trust!(trust)
|
|
19
|
+
|
|
20
|
+
@text = text.dup.freeze
|
|
21
|
+
@trust = trust
|
|
22
|
+
@source = source.dup.freeze
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raises TaintedContentError for tainted content. Use #render instead.
|
|
27
|
+
def to_s
|
|
28
|
+
if tainted?
|
|
29
|
+
raise Spurline::TaintedContentError,
|
|
30
|
+
"Cannot convert tainted content (trust: #{trust}, source: #{source}) to string. " \
|
|
31
|
+
"Use Content#render to get a safely fenced string."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
text
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the content as a string, applying XML data fencing for tainted content.
|
|
38
|
+
# This is the ONLY safe way to extract a string from tainted content.
|
|
39
|
+
def render
|
|
40
|
+
return text unless tainted?
|
|
41
|
+
|
|
42
|
+
<<~XML.strip
|
|
43
|
+
<external_data trust="#{trust}" source="#{source}">
|
|
44
|
+
#{text}
|
|
45
|
+
</external_data>
|
|
46
|
+
XML
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tainted?
|
|
50
|
+
TAINTED_LEVELS.include?(trust)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ==(other)
|
|
54
|
+
other.is_a?(Content) &&
|
|
55
|
+
text == other.text &&
|
|
56
|
+
trust == other.trust &&
|
|
57
|
+
source == other.source
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inspect
|
|
61
|
+
"#<Spurline::Security::Content trust=#{trust} source=#{source.inspect} " \
|
|
62
|
+
"text=#{text[0..50].inspect}#{text.length > 50 ? "..." : ""}>"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def validate_trust!(trust)
|
|
68
|
+
return if TRUST_LEVELS.include?(trust)
|
|
69
|
+
|
|
70
|
+
raise Spurline::ConfigurationError,
|
|
71
|
+
"Invalid trust level: #{trust.inspect}. " \
|
|
72
|
+
"Must be one of: #{TRUST_LEVELS.map(&:inspect).join(", ")}."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
# The only path content takes to the LLM. Every LLM call assembles context
|
|
6
|
+
# through this pipeline. The stages run in fixed order and cannot be reordered.
|
|
7
|
+
#
|
|
8
|
+
# Pipeline stages:
|
|
9
|
+
# 1. Injection scanning — detect and block prompt injection attempts
|
|
10
|
+
# 2. PII filtering — redact/block/warn on personally identifiable information
|
|
11
|
+
# 3. Data fencing — render tainted content with XML fencing
|
|
12
|
+
#
|
|
13
|
+
# Input: Array of Content objects at various trust levels
|
|
14
|
+
# Output: Array of rendered strings, safe for inclusion in an LLM prompt
|
|
15
|
+
class ContextPipeline
|
|
16
|
+
def initialize(guardrails: {})
|
|
17
|
+
@scanner = InjectionScanner.new(
|
|
18
|
+
level: guardrails.fetch(:injection_filter, :strict)
|
|
19
|
+
)
|
|
20
|
+
@pii_filter = PIIFilter.new(
|
|
21
|
+
mode: guardrails.fetch(:pii_filter, :off)
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Processes an array of Content objects through the full security pipeline.
|
|
26
|
+
# Returns an array of safe, rendered strings ready for the LLM.
|
|
27
|
+
#
|
|
28
|
+
# Raises InjectionAttemptError if injection patterns are detected.
|
|
29
|
+
def process(contents)
|
|
30
|
+
contents.map do |content|
|
|
31
|
+
validate_content!(content)
|
|
32
|
+
scan!(content)
|
|
33
|
+
filtered = filter(content)
|
|
34
|
+
filtered.render
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_content!(content)
|
|
41
|
+
return if content.is_a?(Content)
|
|
42
|
+
|
|
43
|
+
raise Spurline::TaintedContentError,
|
|
44
|
+
"ContextPipeline received #{content.class.name} instead of " \
|
|
45
|
+
"Spurline::Security::Content. All content must enter through a Gate. " \
|
|
46
|
+
"Raw strings are never allowed in the pipeline."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def scan!(content)
|
|
50
|
+
@scanner.scan!(content)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def filter(content)
|
|
54
|
+
@pii_filter.filter(content)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
module Gates
|
|
6
|
+
# Abstract base class for security gates. Each gate wraps raw input
|
|
7
|
+
# into a Content object with the appropriate trust level and source.
|
|
8
|
+
#
|
|
9
|
+
# All external data enters the framework through exactly one of four gates.
|
|
10
|
+
# Nothing bypasses a gate.
|
|
11
|
+
class Base
|
|
12
|
+
class << self
|
|
13
|
+
# Wraps raw text into a Content object with the gate's trust level.
|
|
14
|
+
# Subclasses must implement #trust_level and #source_for.
|
|
15
|
+
def wrap(text, **metadata)
|
|
16
|
+
Content.new(
|
|
17
|
+
text: text,
|
|
18
|
+
trust: trust_level,
|
|
19
|
+
source: source_for(**metadata)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def trust_level
|
|
26
|
+
raise NotImplementedError, "#{name} must implement .trust_level"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def source_for(**_metadata)
|
|
30
|
+
raise NotImplementedError, "#{name} must implement .source_for"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
module Gates
|
|
6
|
+
# Gate for developer-authored configuration. Trust level: :operator.
|
|
7
|
+
class OperatorConfig < Base
|
|
8
|
+
class << self
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def trust_level
|
|
12
|
+
:operator
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def source_for(key: "config", **)
|
|
16
|
+
"config:#{key}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
module Gates
|
|
6
|
+
# Gate for framework and persona prompts. Trust level: :system.
|
|
7
|
+
# System prompts are trusted by definition and bypass the injection scanner.
|
|
8
|
+
class SystemPrompt < Base
|
|
9
|
+
class << self
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def trust_level
|
|
13
|
+
:system
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def source_for(persona: "default", **)
|
|
17
|
+
"persona:#{persona}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
module Gates
|
|
6
|
+
# Gate for tool execution results. Trust level: :external.
|
|
7
|
+
# Tool results are always tainted — they come from outside the trust boundary.
|
|
8
|
+
class ToolResult < Base
|
|
9
|
+
class << self
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def trust_level
|
|
13
|
+
:external
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def source_for(tool_name: "unknown", **)
|
|
17
|
+
"tool:#{tool_name}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
module Gates
|
|
6
|
+
# Gate for live user messages. Trust level: :user.
|
|
7
|
+
class UserInput < Base
|
|
8
|
+
class << self
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def trust_level
|
|
12
|
+
:user
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def source_for(user_id: "anonymous", **)
|
|
16
|
+
"user:#{user_id}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
# Scans Content objects for prompt injection patterns.
|
|
6
|
+
# Configurable strictness: :strict, :moderate, :permissive.
|
|
7
|
+
#
|
|
8
|
+
# Only scans content at trust levels that could be injected (:user, :external, :untrusted).
|
|
9
|
+
# System and operator content is trusted by definition and bypasses scanning.
|
|
10
|
+
#
|
|
11
|
+
# Pattern tiers are additive: :strict includes all :moderate patterns,
|
|
12
|
+
# :moderate includes all :permissive (BASE) patterns.
|
|
13
|
+
class InjectionScanner
|
|
14
|
+
SKIP_TRUST_LEVELS = %i[system operator].freeze
|
|
15
|
+
|
|
16
|
+
# Patterns checked at all strictness levels — the most obvious injection attempts.
|
|
17
|
+
BASE_PATTERNS = [
|
|
18
|
+
/ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions|prompts|context|rules)/i,
|
|
19
|
+
/you\s+are\s+now\s+(a|an|in)\s+/i,
|
|
20
|
+
/\bsystem\s*:\s*\n/i,
|
|
21
|
+
/\bforget\s+(all\s+|everything\s+)?(previous|prior|your)\s+(instructions|context|rules|training)/i,
|
|
22
|
+
/\bdisregard\s+(all\s+)?(previous|prior|above|your)\s+(instructions|prompts|rules)/i,
|
|
23
|
+
/\bnew\s+instructions\s*:/i,
|
|
24
|
+
/\bpretend\s+(you\s+are|to\s+be|that\s+you)/i,
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Additional patterns for :moderate and :strict — social engineering and role manipulation.
|
|
28
|
+
MODERATE_PATTERNS = [
|
|
29
|
+
/\bdo\s+not\s+follow\b/i,
|
|
30
|
+
/\boverride\s+(your|the)\s+(instructions|rules|guidelines|programming)\b/i,
|
|
31
|
+
/\bact\s+as\s+(if\s+you\s+are|though\s+you|a\b)/i,
|
|
32
|
+
/\bbehave\s+as\s+(if|though|a\b)/i,
|
|
33
|
+
/\bfrom\s+now\s+on\s*,?\s*(you|your|act|behave|respond|ignore)/i,
|
|
34
|
+
/\bjailbreak/i,
|
|
35
|
+
/\bdeveloper\s+mode\b/i,
|
|
36
|
+
/\bDAN\s+(mode|prompt)\b/i,
|
|
37
|
+
/\bdo\s+anything\s+now\b/i,
|
|
38
|
+
/\bunfiltered\s+(mode|response|output)\b/i,
|
|
39
|
+
/\bno\s+(restrictions|rules|limitations|filters|censorship)\b/i,
|
|
40
|
+
/\bbypass\s+(your|the|any|all)\s+(restrictions|rules|filters|safety|guidelines)/i,
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Additional patterns for :strict only — structural attacks and format manipulation.
|
|
44
|
+
STRICT_PATTERNS = [
|
|
45
|
+
/\brole\s*:\s*(system|assistant)\b/i,
|
|
46
|
+
/<\/?system>/i,
|
|
47
|
+
/\[INST\]/i,
|
|
48
|
+
/<<\s*SYS\s*>>/i,
|
|
49
|
+
/<\|im_start\|>/i,
|
|
50
|
+
/\bIMPORTANT\s*:\s*(new|override|ignore|forget|disregard|update)/i,
|
|
51
|
+
/\bATTENTION\s*:\s*(new|override|ignore|forget|disregard|update)/i,
|
|
52
|
+
/\b(BEGIN|END)\s+(SYSTEM|INSTRUCTION|PROMPT)\b/i,
|
|
53
|
+
/---+\s*\n\s*(system|instruction|new prompt|override)/i,
|
|
54
|
+
/\bbase64\s*[\s:]+[A-Za-z0-9+\/=]{20,}/i,
|
|
55
|
+
/\btranslate\s+(the\s+)?(following|this)\s+(from|to)\s+.*\s+(and|then)\s+(ignore|forget|override)/i,
|
|
56
|
+
/\brepeat\s+(the\s+)?(system\s+prompt|instructions|your\s+rules)/i,
|
|
57
|
+
/\b(reveal|show|display|output|print)\s+(your|the)\s+(system\s+prompt|instructions|rules)/i,
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
LEVELS = %i[strict moderate permissive].freeze
|
|
61
|
+
|
|
62
|
+
attr_reader :level
|
|
63
|
+
|
|
64
|
+
def initialize(level: :strict)
|
|
65
|
+
validate_level!(level)
|
|
66
|
+
@level = level
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Scans a Content object for injection patterns.
|
|
70
|
+
# Returns nil if clean, raises InjectionAttemptError if detected.
|
|
71
|
+
def scan!(content)
|
|
72
|
+
return if SKIP_TRUST_LEVELS.include?(content.trust)
|
|
73
|
+
|
|
74
|
+
text = content.text
|
|
75
|
+
patterns_for_level.each do |pattern|
|
|
76
|
+
next unless text.match?(pattern)
|
|
77
|
+
|
|
78
|
+
raise Spurline::InjectionAttemptError,
|
|
79
|
+
"Injection pattern detected in content (trust: #{content.trust}, " \
|
|
80
|
+
"source: #{content.source}). Pattern: #{pattern.source[0..40]}. " \
|
|
81
|
+
"Review the content or adjust injection_filter level."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def patterns_for_level
|
|
90
|
+
case level
|
|
91
|
+
when :strict
|
|
92
|
+
BASE_PATTERNS + MODERATE_PATTERNS + STRICT_PATTERNS
|
|
93
|
+
when :moderate
|
|
94
|
+
BASE_PATTERNS + MODERATE_PATTERNS
|
|
95
|
+
when :permissive
|
|
96
|
+
BASE_PATTERNS
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_level!(level)
|
|
101
|
+
return if LEVELS.include?(level)
|
|
102
|
+
|
|
103
|
+
raise Spurline::ConfigurationError,
|
|
104
|
+
"Invalid injection filter level: #{level.inspect}. " \
|
|
105
|
+
"Must be one of: #{LEVELS.map(&:inspect).join(", ")}."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Security
|
|
5
|
+
# Filters personally identifiable information from Content objects.
|
|
6
|
+
# Modes:
|
|
7
|
+
# :redact — replaces detected PII with [REDACTED_<type>] placeholders
|
|
8
|
+
# :block — raises PIIDetectedError if PII is found
|
|
9
|
+
# :warn — returns content unchanged but records detections for audit
|
|
10
|
+
# :off — no scanning, returns content unchanged
|
|
11
|
+
#
|
|
12
|
+
# Only scans content at trust levels where PII matters (:user, :external, :untrusted).
|
|
13
|
+
# System and operator content is trusted by definition and bypasses PII filtering.
|
|
14
|
+
class PIIFilter
|
|
15
|
+
MODES = %i[redact block warn off].freeze
|
|
16
|
+
|
|
17
|
+
SKIP_TRUST_LEVELS = %i[system operator].freeze
|
|
18
|
+
|
|
19
|
+
# Pattern definitions: [label, regex, replacement_tag]
|
|
20
|
+
# Ordered from most specific to least specific to prevent partial matches.
|
|
21
|
+
PII_PATTERNS = [
|
|
22
|
+
[:ssn, /\b\d{3}-\d{2}-\d{4}\b/, "[REDACTED_SSN]"],
|
|
23
|
+
[:credit_card, /\b(?:\d{4}[\s-]?){3}\d{4}\b/, "[REDACTED_CREDIT_CARD]"],
|
|
24
|
+
[:phone, /\b(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/, "[REDACTED_PHONE]"],
|
|
25
|
+
[:email, /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, "[REDACTED_EMAIL]"],
|
|
26
|
+
[:ip_address, /\b(?:\d{1,3}\.){3}\d{1,3}\b/, "[REDACTED_IP]"],
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :mode
|
|
30
|
+
|
|
31
|
+
def initialize(mode: :off)
|
|
32
|
+
validate_mode!(mode)
|
|
33
|
+
@mode = mode
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Filters a Content object for PII.
|
|
37
|
+
# Returns the original content if no PII is detected or mode is :off.
|
|
38
|
+
# Returns a new Content object with redacted text in :redact mode.
|
|
39
|
+
# Raises PIIDetectedError in :block mode.
|
|
40
|
+
# Returns the original content with detections array in :warn mode.
|
|
41
|
+
def filter(content)
|
|
42
|
+
return content if mode == :off
|
|
43
|
+
return content if SKIP_TRUST_LEVELS.include?(content.trust)
|
|
44
|
+
|
|
45
|
+
detections = detect(content.text)
|
|
46
|
+
return content if detections.empty?
|
|
47
|
+
|
|
48
|
+
case mode
|
|
49
|
+
when :redact
|
|
50
|
+
redact(content, detections)
|
|
51
|
+
when :block
|
|
52
|
+
block!(content, detections)
|
|
53
|
+
when :warn
|
|
54
|
+
# In :warn mode, content passes through unchanged.
|
|
55
|
+
# The caller (ContextPipeline) can check detections via the return.
|
|
56
|
+
# For now, we simply return the content — audit logging is deferred.
|
|
57
|
+
content
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Scans text for PII patterns. Returns array of {type:, match:, pattern:} hashes.
|
|
62
|
+
def detect(text)
|
|
63
|
+
detections = []
|
|
64
|
+
PII_PATTERNS.each do |label, pattern, _|
|
|
65
|
+
text.scan(pattern) do |match|
|
|
66
|
+
detected = match.is_a?(Array) ? match.first : $~.to_s
|
|
67
|
+
detections << { type: label, match: detected, pattern: pattern }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
detections
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def redact(content, detections)
|
|
76
|
+
redacted_text = content.text.dup
|
|
77
|
+
PII_PATTERNS.each do |_, pattern, replacement|
|
|
78
|
+
redacted_text.gsub!(pattern, replacement)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Content.new(
|
|
82
|
+
text: redacted_text,
|
|
83
|
+
trust: content.trust,
|
|
84
|
+
source: content.source
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def block!(content, detections)
|
|
89
|
+
types = detections.map { |d| d[:type] }.uniq.join(", ")
|
|
90
|
+
raise Spurline::PIIDetectedError,
|
|
91
|
+
"PII detected in content (trust: #{content.trust}, source: #{content.source}). " \
|
|
92
|
+
"Types found: #{types}. Set pii_filter to :redact or :off to allow this content."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_mode!(mode)
|
|
96
|
+
return if MODES.include?(mode)
|
|
97
|
+
|
|
98
|
+
raise Spurline::ConfigurationError,
|
|
99
|
+
"Invalid PII filter mode: #{mode.inspect}. " \
|
|
100
|
+
"Must be one of: #{MODES.map(&:inspect).join(", ")}."
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3782 | 10:23 PM | 🔵 | Spurline session and state machine architecture analyzed for suspended sessions feature | ~700 |
|
|
11
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Session
|
|
5
|
+
# Handles restoring a resumed session's turn history into short-term memory.
|
|
6
|
+
# When a session is resumed by ID, completed turns are replayed into the
|
|
7
|
+
# memory manager so the agent has context from the previous conversation.
|
|
8
|
+
class Resumption
|
|
9
|
+
attr_reader :restored_count
|
|
10
|
+
|
|
11
|
+
def initialize(session:, memory:)
|
|
12
|
+
@session = session
|
|
13
|
+
@memory = memory
|
|
14
|
+
@restored_count = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Restores the session's completed turn history into the memory manager.
|
|
18
|
+
# Incomplete turns are skipped — they represent interrupted work.
|
|
19
|
+
def restore!
|
|
20
|
+
@session.turns.each do |turn|
|
|
21
|
+
next unless turn.complete?
|
|
22
|
+
|
|
23
|
+
@memory.add_turn(turn)
|
|
24
|
+
@restored_count += 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@restored_count
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether this session has prior turns to restore.
|
|
31
|
+
def resumable?
|
|
32
|
+
@session.turns.any?(&:complete?)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Spurline
|
|
7
|
+
module Session
|
|
8
|
+
# JSON serializer/deserializer for persisted sessions.
|
|
9
|
+
# Includes a format_version field for forward-compatible migrations.
|
|
10
|
+
class Serializer
|
|
11
|
+
FORMAT_VERSION = 1
|
|
12
|
+
CONTENT_TYPE = "Spurline::Security::Content"
|
|
13
|
+
TIME_TYPE = "Time"
|
|
14
|
+
SYMBOL_TYPE = "Symbol"
|
|
15
|
+
|
|
16
|
+
def to_json(session)
|
|
17
|
+
JSON.generate(
|
|
18
|
+
format_version: FORMAT_VERSION,
|
|
19
|
+
session: serialize_session(session)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def from_json(json, store:)
|
|
24
|
+
payload = JSON.parse(json)
|
|
25
|
+
unless payload.is_a?(Hash)
|
|
26
|
+
raise Spurline::SessionDeserializationError, "Session payload must be a JSON object."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
version = payload.fetch("format_version")
|
|
30
|
+
unless version == FORMAT_VERSION
|
|
31
|
+
raise Spurline::SessionDeserializationError,
|
|
32
|
+
"Unsupported session format version: #{version.inspect}."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
session_data = deserialize_session(payload.fetch("session"))
|
|
36
|
+
Session.restore(session_data, store: store)
|
|
37
|
+
rescue Spurline::SessionDeserializationError
|
|
38
|
+
raise
|
|
39
|
+
rescue JSON::ParserError, KeyError, TypeError, NoMethodError, ArgumentError, Spurline::ConfigurationError => e
|
|
40
|
+
raise Spurline::SessionDeserializationError, "Failed to deserialize session payload: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def serialize_session(session)
|
|
46
|
+
{
|
|
47
|
+
id: session.id,
|
|
48
|
+
agent_class: session.agent_class,
|
|
49
|
+
user: session.user,
|
|
50
|
+
state: session.state.to_s,
|
|
51
|
+
started_at: serialize_value(session.started_at),
|
|
52
|
+
finished_at: serialize_value(session.finished_at),
|
|
53
|
+
metadata: serialize_value(session.metadata),
|
|
54
|
+
turns: session.turns.map { |turn| serialize_turn(turn) },
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def serialize_turn(turn)
|
|
59
|
+
{
|
|
60
|
+
input: serialize_value(turn.input),
|
|
61
|
+
output: serialize_value(turn.output),
|
|
62
|
+
tool_calls: serialize_value(turn.tool_calls),
|
|
63
|
+
number: turn.number,
|
|
64
|
+
started_at: serialize_value(turn.started_at),
|
|
65
|
+
finished_at: serialize_value(turn.finished_at),
|
|
66
|
+
metadata: serialize_value(turn.metadata),
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def serialize_value(value)
|
|
71
|
+
case value
|
|
72
|
+
when Spurline::Security::Content
|
|
73
|
+
{
|
|
74
|
+
__type: CONTENT_TYPE,
|
|
75
|
+
text: value.text,
|
|
76
|
+
trust: value.trust.to_s,
|
|
77
|
+
source: value.source,
|
|
78
|
+
}
|
|
79
|
+
when Time
|
|
80
|
+
{
|
|
81
|
+
__type: TIME_TYPE,
|
|
82
|
+
iso8601: value.utc.iso8601(6),
|
|
83
|
+
}
|
|
84
|
+
when Symbol
|
|
85
|
+
{
|
|
86
|
+
__type: SYMBOL_TYPE,
|
|
87
|
+
value: value.to_s,
|
|
88
|
+
}
|
|
89
|
+
when Array
|
|
90
|
+
value.map { |item| serialize_value(item) }
|
|
91
|
+
when Hash
|
|
92
|
+
value.each_with_object({}) do |(key, item), hash|
|
|
93
|
+
hash[key.to_s] = serialize_value(item)
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def deserialize_session(data)
|
|
101
|
+
hash = deserialize_hash(data)
|
|
102
|
+
{
|
|
103
|
+
id: hash.fetch(:id),
|
|
104
|
+
agent_class: hash[:agent_class],
|
|
105
|
+
user: hash[:user],
|
|
106
|
+
turns: Array(hash[:turns]).map { |turn_data| Turn.restore(deserialize_turn(turn_data)) },
|
|
107
|
+
state: hash.fetch(:state).to_sym,
|
|
108
|
+
started_at: hash.fetch(:started_at),
|
|
109
|
+
finished_at: hash[:finished_at],
|
|
110
|
+
metadata: hash[:metadata] || {},
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def deserialize_turn(data)
|
|
115
|
+
hash = deserialize_hash(data)
|
|
116
|
+
{
|
|
117
|
+
input: hash[:input],
|
|
118
|
+
output: hash[:output],
|
|
119
|
+
tool_calls: hash[:tool_calls] || [],
|
|
120
|
+
number: hash.fetch(:number),
|
|
121
|
+
started_at: hash.fetch(:started_at),
|
|
122
|
+
finished_at: hash[:finished_at],
|
|
123
|
+
metadata: hash[:metadata] || {},
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def deserialize_hash(value)
|
|
128
|
+
hash = deserialize_value(value)
|
|
129
|
+
unless hash.is_a?(Hash)
|
|
130
|
+
raise TypeError, "Expected Hash value, got #{hash.class}."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
hash
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def deserialize_value(value)
|
|
137
|
+
case value
|
|
138
|
+
when Array
|
|
139
|
+
value.map { |item| deserialize_value(item) }
|
|
140
|
+
when Hash
|
|
141
|
+
deserialize_hash_value(value)
|
|
142
|
+
else
|
|
143
|
+
value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def deserialize_hash_value(value)
|
|
148
|
+
type = value["__type"]
|
|
149
|
+
|
|
150
|
+
case type
|
|
151
|
+
when CONTENT_TYPE
|
|
152
|
+
Spurline::Security::Content.new(
|
|
153
|
+
text: value.fetch("text"),
|
|
154
|
+
trust: value.fetch("trust").to_sym,
|
|
155
|
+
source: value.fetch("source")
|
|
156
|
+
)
|
|
157
|
+
when TIME_TYPE
|
|
158
|
+
Time.iso8601(value.fetch("iso8601"))
|
|
159
|
+
when SYMBOL_TYPE
|
|
160
|
+
value.fetch("value").to_sym
|
|
161
|
+
else
|
|
162
|
+
value.each_with_object({}) do |(key, item), hash|
|
|
163
|
+
hash[key.to_sym] = deserialize_value(item)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|