diff --git a/.github/workflows/sovrint-extension-validate.yml b/.github/workflows/sovrint-extension-validate.yml new file mode 100644 index 0000000000..d3dca7d5f4 --- /dev/null +++ b/.github/workflows/sovrint-extension-validate.yml @@ -0,0 +1,165 @@ +name: SOVRINT Extension Validation + +on: + pull_request: + paths: + - "docs/sovrint/**" + - "sovrint/**" + - "nodejs/src/sovrint.ts" + - "nodejs/test/sovrint.test.ts" + - "nodejs/src/index.ts" + - "python/copilot/sovrint.py" + - "python/test_sovrint.py" + - "python/copilot/__init__.py" + - "go/sovrint.go" + - "go/sovrint_test.go" + - "cookbook/**/sovrint-governed-session.md" + - "SOVRINT_*.md" + - ".github/workflows/sovrint-extension-validate.yml" + push: + branches: + - main + - feat/sovrint-governed-sdk-profile-v1 + +permissions: + contents: read + +jobs: + common-contracts: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install validators + run: python -m pip install --disable-pip-version-check PyYAML==6.0.2 jsonschema==4.23.0 + + - name: Validate JSON and YAML contracts + run: | + python - <<'PY' + import json + from pathlib import Path + + import yaml + from jsonschema import Draft202012Validator, FormatChecker + + for path in Path("sovrint").rglob("*.json"): + json.loads(path.read_text(encoding="utf-8")) + print(f"valid json: {path}") + + for path in Path("sovrint").rglob("*.yaml"): + yaml.safe_load(path.read_text(encoding="utf-8")) + print(f"valid yaml: {path}") + + profile_schema = json.loads( + Path("sovrint/schemas/security-profile.schema.json").read_text(encoding="utf-8") + ) + profile_validator = Draft202012Validator( + profile_schema, + format_checker=FormatChecker(), + ) + for path in Path("sovrint/profiles").glob("*.json"): + profile = json.loads(path.read_text(encoding="utf-8")) + profile_validator.validate(profile) + if profile["audit"]["includeArguments"] is not False: + raise SystemExit(f"profile exposes arguments: {path}") + if profile["audit"]["includeResults"] is not False: + raise SystemExit(f"profile exposes results: {path}") + print(f"valid profile: {path}") + + event_schema = json.loads( + Path("sovrint/schemas/audit-event.schema.json").read_text(encoding="utf-8") + ) + event = json.loads( + Path("sovrint/examples/audit-event.example.json").read_text(encoding="utf-8") + ) + Draft202012Validator( + event_schema, + format_checker=FormatChecker(), + ).validate(event) + if event.get("metadata", {}).get("simulationOnly") is not True: + raise SystemExit("audit example must remain simulation-only") + print("valid audit event example") + PY + + - name: Validate extension manifest paths + run: | + python - <<'PY' + from pathlib import Path + + import yaml + + manifest = yaml.safe_load(Path("sovrint/manifest.yaml").read_text(encoding="utf-8")) + listed = [] + for value in manifest["components"].values(): + listed.extend(value) + missing = [path for path in listed if not Path(path).exists()] + if missing: + raise SystemExit(f"manifest paths missing: {missing}") + print(f"validated {len(listed)} manifest paths") + PY + + nodejs: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: nodejs/package-lock.json + + - name: Install Node.js dependencies + working-directory: nodejs + run: npm ci + + - name: Typecheck Node.js SDK + working-directory: nodejs + run: npm run typecheck + + - name: Test SOVRINT TypeScript helpers + working-directory: nodejs + run: npx vitest run test/sovrint.test.ts + + python: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python SDK and test dependencies + run: python -m pip install --disable-pip-version-check -e "./python[dev]" + + - name: Compile Python helper + run: python -m compileall -q python/copilot/sovrint.py + + - name: Test SOVRINT Python helpers + run: pytest -q python/test_sovrint.py + + go: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + + - name: Test SOVRINT Go helpers + working-directory: go + run: go test -run Sovrint ./... diff --git a/SOVRINT_CHANGELOG.md b/SOVRINT_CHANGELOG.md new file mode 100644 index 0000000000..b79a465c47 --- /dev/null +++ b/SOVRINT_CHANGELOG.md @@ -0,0 +1,25 @@ +# SOVRINT™ Extension Changelog + +## v1.0 — 2026-06-26 + +Added: + +- governed-session architecture and security documentation; +- governance, integrity, and evidence interface guide; +- strict and read-only JSON profiles; +- research profile constants in TypeScript, Python, and Go; +- shared profile and audit-event schemas; +- extension manifest and permission matrix; +- bounded audit-event example; +- TypeScript helper module and tests; +- Python helper module and tests; +- Go helper module and tests; +- TypeScript, Python, Go, and .NET recipes; +- root extension index and attribution; +- cross-language validation workflow. + +Compatibility: + +- upstream JSON-RPC protocol unchanged; +- existing SDK users unaffected unless they apply the SOVRINT helpers; +- existing package names and client/session APIs preserved. diff --git a/SOVRINT_EXTENSION.md b/SOVRINT_EXTENSION.md new file mode 100644 index 0000000000..4f47b9a12c --- /dev/null +++ b/SOVRINT_EXTENSION.md @@ -0,0 +1,24 @@ +# SOVRINT™ Governed SDK Extension + +**Author:** Katrina Pietroniro +**Version:** 1.0 +**Status:** Additive and opt-in + +This repository includes a governed-session extension around the existing Copilot SDK interfaces. The upstream JSON-RPC protocol remains unchanged. + +The extension includes permission profiles, session-configuration helpers, custom-tool guards, bounded audit events, shared schemas, tests, and cross-language recipes. + +## Documentation + +- [Architecture](docs/sovrint/README.md) +- [Security profile](docs/sovrint/SECURITY_PROFILE.md) +- [Governance, integrity, and evidence interfaces](docs/sovrint/GOVERNANCE_INTEGRITY_EVIDENCE.md) + +## Recipes + +- [TypeScript](cookbook/nodejs/sovrint-governed-session.md) +- [Python](cookbook/python/sovrint-governed-session.md) +- [Go](cookbook/go/sovrint-governed-session.md) +- [.NET](cookbook/dotnet/sovrint-governed-session.md) + +Existing SDK behavior remains unchanged unless an application applies the extension helpers. diff --git a/SOVRINT_EXTENSION_ATTRIBUTION.md b/SOVRINT_EXTENSION_ATTRIBUTION.md new file mode 100644 index 0000000000..f31f1b6e12 --- /dev/null +++ b/SOVRINT_EXTENSION_ATTRIBUTION.md @@ -0,0 +1,8 @@ +# SOVRINT™ Extension Attribution + +**Author:** Katrina Pietroniro +**Initial integration date:** 2026-06-26 + +This attribution applies to the governed-session profiles, permission composition, session constraints, custom-tool guards, bounded audit events, interface documentation, schemas, tests, and recipes added under the SOVRINT extension. + +The original Copilot SDK code remains attributed to its existing authors. Repository licensing remains unchanged. diff --git a/cookbook/dotnet/sovrint-governed-session.md b/cookbook/dotnet/sovrint-governed-session.md new file mode 100644 index 0000000000..78bd2499fb --- /dev/null +++ b/cookbook/dotnet/sovrint-governed-session.md @@ -0,0 +1,107 @@ +# SOVRINT Governed Session — .NET + +The TypeScript, Python, and Go packages in this repository include first-class SOVRINT helper modules. This .NET recipe applies the same control principles through the documented native `SessionConfig` surface. + +## Conservative profile + +```csharp +using GitHub.Copilot.SDK; + +const string SovrintSystemAppend = @" +Operate under a bounded SOVRINT governed-session profile. +Use only explicitly exposed tools and declared authority. +Treat observations, inferences, recommendations, governance decisions, +integrity findings, and accepted evidence as distinct classes. +Do not claim approval, verification, restoration, or EvidenceGrid acceptance +without an explicit external result. +"; + +static SessionConfig ApplySovrintStrictProfile(SessionConfig config) +{ + if (config.SystemMessage?.Mode == SystemMessageMode.Replace) + { + throw new InvalidOperationException( + "The SOVRINT strict profile forbids system-message replacement." + ); + } + + var existing = config.SystemMessage?.Content; + config.SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Append, + Content = string.Join( + Environment.NewLine + Environment.NewLine, + new[] { existing, SovrintSystemAppend }.Where(value => !string.IsNullOrWhiteSpace(value)) + ) + }; + + // An explicit empty allowlist exposes no inherited first-party tools. + config.AvailableTools = []; + + // Caller-defined tools must be explicitly supplied after review. + config.Tools = []; + + return config; +} + +await using var client = new CopilotClient(); +await client.StartAsync(); + +var config = ApplySovrintStrictProfile(new SessionConfig +{ + Model = "gpt-5", + Streaming = true, +}); + +await using var session = await client.CreateSessionAsync(config); +await session.SendAsync(new MessageOptions +{ + Prompt = "Summarize the supplied text without using tools." +}); +``` + +## Read-oriented profile + +A read-oriented deployment should provide an explicit `AvailableTools` allowlist containing only reviewed read operations. Do not infer tool safety from names alone; validate the actual SDK and CLI tool identifiers available in the deployed version. + +```csharp +var config = new SessionConfig +{ + Model = "gpt-5", + Streaming = true, + AvailableTools = + [ + // Add only reviewed read-tool identifiers for the deployed CLI version. + ], + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Append, + Content = SovrintSystemAppend + Environment.NewLine + + "Operate in read-only mode." + } +}; +``` + +## Custom tools + +Custom tools are application code. Wrap their handlers with application authorization and bounded audit recording before placing them in `SessionConfig.Tools`. + +The wrapper should record only: + +- session reference; +- tool name; +- invocation reference; +- decision; +- reason code; +- timestamp; +- evidence status. + +It should not place arguments, results, credentials, prompts, or raw file contents into the audit event. + +## Boundaries + +- system-message append content is guidance, not a complete enforcement boundary; +- an empty tool allowlist is safer than inheriting an unknown tool surface; +- local application approval is not a global governance decision; +- an audit event is not EvidenceGrid acceptance; +- unknown or unsupported permission surfaces should fail closed. diff --git a/cookbook/go/sovrint-governed-session.md b/cookbook/go/sovrint-governed-session.md new file mode 100644 index 0000000000..a41992f5e1 --- /dev/null +++ b/cookbook/go/sovrint-governed-session.md @@ -0,0 +1,104 @@ +# SOVRINT Governed Session — Go + +This recipe creates a Copilot SDK session with a bounded SOVRINT profile, a fail-closed audit sink, and a guarded custom tool. + +## Example + +```go +package main + +import ( + "fmt" + "log" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + auditEvents := make([]copilot.SovrintAuditEvent, 0) + auditSink := func(event copilot.SovrintAuditEvent) error { + // Replace this with a bounded append-only sink. + // Do not add prompts, tool arguments, results, or credentials. + auditEvents = append(auditEvents, event) + return nil + } + + inspectState := copilot.Tool{ + Name: "inspect_state", + Description: "Return a bounded health summary for a named component", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "component": map[string]interface{}{"type": "string"}, + }, + "required": []string{"component"}, + }, + Handler: func(invocation copilot.ToolInvocation) (copilot.ToolResult, error) { + return copilot.ToolResult{ + TextResultForLLM: `{"status":"observation-only"}`, + ResultType: "success", + }, nil + }, + } + + governedInspectState := copilot.WrapSovrintTool( + inspectState, + copilot.SovrintToolGuardOptions{ + Profile: copilot.SovrintReadOnlyProfile, + AuditSink: auditSink, + Authorize: func(invocation copilot.ToolInvocation) (bool, error) { + return invocation.SessionID != "", nil + }, + }, + ) + + config, err := copilot.ApplySovrintProfile( + &copilot.SessionConfig{ + Model: "gpt-5", + Tools: []copilot.Tool{governedInspectState}, + Streaming: true, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "append", + Content: "Return observations without representing them as governance decisions.", + }, + }, + copilot.SovrintReadOnlyProfile, + copilot.SovrintApplyProfileOptions{AuditSink: auditSink}, + ) + if err != nil { + log.Fatal(err) + } + + client := copilot.NewClient(nil) + if err := client.Start(); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(config) + if err != nil { + log.Fatal(err) + } + + response, err := session.SendAndWait(copilot.MessageOptions{ + Prompt: "Use inspect_state for component registry-alpha and summarize the observation.", + }, 0) + if err != nil { + log.Fatal(err) + } + + fmt.Println(*response.Data.Content) + fmt.Printf("Recorded %d bounded audit events.\n", len(auditEvents)) +} +``` + +## What the profile enforces + +- `read` permission requests may pass. +- `write`, `shell`, `url`, and `mcp` requests are denied. +- system-message replacement returns an error before session creation. +- any pre-existing permission handler is composed using most-restrictive-wins semantics. +- the wrapped tool records start, authorization, completion, rejection, or failure events. +- tool arguments and results are not placed in SOVRINT audit events. + +Use `SovrintStrictProfile` to expose no inherited first-party tool surface. Use `SovrintResearchProfile` with `EvaluatePermission` for narrowly scoped application decisions. diff --git a/cookbook/nodejs/README.md b/cookbook/nodejs/README.md index afe3aa7528..a3aaa18679 100644 --- a/cookbook/nodejs/README.md +++ b/cookbook/nodejs/README.md @@ -1,19 +1,20 @@ -# GitHub Copilot SDK Cookbook — Node.js / TypeScript - -This folder hosts short, practical recipes for using the GitHub Copilot SDK with Node.js/TypeScript. Each recipe is concise, copy‑pasteable, and points to fuller examples and tests. - -## Recipes - -- [Error Handling](error-handling.md): Handle errors gracefully including connection failures, timeouts, and cleanup. -- [Multiple Sessions](multiple-sessions.md): Manage multiple independent conversations simultaneously. -- [Managing Local Files](managing-local-files.md): Organize files by metadata using AI-powered grouping strategies. -- [PR Visualization](pr-visualization.md): Generate interactive PR age charts using GitHub MCP Server. -- [Persisting Sessions](persisting-sessions.md): Save and resume sessions across restarts. - -## Contributing - -Add a new recipe by creating a markdown file in this folder and linking it above. Follow repository guidance in [CONTRIBUTING.md](../../CONTRIBUTING.md). - -## Status - -This README is a scaffold; recipe files are placeholders until populated. +# GitHub Copilot SDK Cookbook — Node.js / TypeScript + +This folder hosts short, practical recipes for using the GitHub Copilot SDK with Node.js/TypeScript. Each recipe is concise, copy‑pasteable, and points to fuller examples and tests. + +## Recipes + +- [SOVRINT Governed Session](sovrint-governed-session.md): Apply deny-by-default profiles, bounded permission handling, custom-tool guards, and audit events. +- [Error Handling](error-handling.md): Handle errors gracefully including connection failures, timeouts, and cleanup. +- [Multiple Sessions](multiple-sessions.md): Manage multiple independent conversations simultaneously. +- [Managing Local Files](managing-local-files.md): Organize files by metadata using AI-powered grouping strategies. +- [PR Visualization](pr-visualization.md): Generate interactive PR age charts using GitHub MCP Server. +- [Persisting Sessions](persisting-sessions.md): Save and resume sessions across restarts. + +## Contributing + +Add a new recipe by creating a markdown file in this folder and linking it above. Follow repository guidance in [CONTRIBUTING.md](../../CONTRIBUTING.md). + +## Status + +The SOVRINT governed-session recipe is implemented and backed by SDK unit tests. Other listed recipes may remain scaffolds until populated. diff --git a/cookbook/nodejs/sovrint-governed-session.md b/cookbook/nodejs/sovrint-governed-session.md new file mode 100644 index 0000000000..1e853677ce --- /dev/null +++ b/cookbook/nodejs/sovrint-governed-session.md @@ -0,0 +1,89 @@ +# SOVRINT Governed Session — Node.js / TypeScript + +This recipe creates a Copilot SDK session with a bounded SOVRINT profile, a fail-closed audit sink, and a guarded custom tool. + +## Example + +```typescript +import { + CopilotClient, + SOVRINT_READ_ONLY_PROFILE, + applySovrintProfile, + defineTool, + wrapSovrintTool, + type SovrintAuditEvent, +} from "@github/copilot-sdk"; + +const auditEvents: SovrintAuditEvent[] = []; +const auditSink = async (event: SovrintAuditEvent) => { + // Replace this with a bounded append-only sink. + // Do not add prompts, tool arguments, results, or credentials. + auditEvents.push(event); +}; + +const inspectState = defineTool("inspect_state", { + description: "Return a bounded health summary for a named component", + parameters: { + type: "object", + properties: { + component: { type: "string" }, + }, + required: ["component"], + }, + handler: async ({ component }: { component: string }) => ({ + component, + status: "observation-only", + }), +}); + +const governedInspectState = wrapSovrintTool(inspectState, { + profile: SOVRINT_READ_ONLY_PROFILE, + auditSink, + authorize: async (_args, invocation) => invocation.sessionId.length > 0, +}); + +const sessionConfig = applySovrintProfile( + { + model: "gpt-5", + tools: [governedInspectState], + streaming: true, + systemMessage: { + mode: "append", + content: "Return observations without representing them as governance decisions.", + }, + }, + SOVRINT_READ_ONLY_PROFILE, + { + auditSink, + } +); + +const client = new CopilotClient(); +const session = await client.createSession(sessionConfig); + +const response = await session.sendAndWait({ + prompt: "Use inspect_state for component registry-alpha and summarize the observation.", +}); + +console.log(response?.data.content); +console.log(`Recorded ${auditEvents.length} bounded audit events.`); + +await client.stop(); +``` + +## What the profile enforces + +- `read` permission requests may pass. +- `write`, `shell`, `url`, and `mcp` requests are denied. +- system-message replacement throws before session creation. +- any pre-existing permission handler is composed using most-restrictive-wins semantics. +- the wrapped tool records start, authorization, completion, rejection, or failure events. +- tool arguments and results are not placed in SOVRINT audit events. + +## Strict mode + +Replace `SOVRINT_READ_ONLY_PROFILE` with `SOVRINT_STRICT_PROFILE` to expose no inherited first-party tool surface and deny every permission kind. Caller-defined tools must still be explicitly supplied and guarded. + +## Research mode + +`SOVRINT_RESEARCH_PROFILE` permits reads and defers all other permission kinds to `evaluatePermission`. The evaluator should use deployment-specific allowlists and must return no decision when it cannot establish scope. diff --git a/cookbook/python/README.md b/cookbook/python/README.md index 885c8be1e2..18dfb5327e 100644 --- a/cookbook/python/README.md +++ b/cookbook/python/README.md @@ -1,19 +1,20 @@ -# GitHub Copilot SDK Cookbook — Python - -This folder hosts short, practical recipes for using the GitHub Copilot SDK with Python. Each recipe is concise, copy‑pasteable, and points to fuller examples and tests. - -## Recipes - -- [Error Handling](error-handling.md): Handle errors gracefully including connection failures, timeouts, and cleanup. -- [Multiple Sessions](multiple-sessions.md): Manage multiple independent conversations simultaneously. -- [Managing Local Files](managing-local-files.md): Organize files by metadata using AI-powered grouping strategies. -- [PR Visualization](pr-visualization.md): Generate interactive PR age charts using GitHub MCP Server. -- [Persisting Sessions](persisting-sessions.md): Save and resume sessions across restarts. - -## Contributing - -Add a new recipe by creating a markdown file in this folder and linking it above. Follow repository guidance in [CONTRIBUTING.md](../../CONTRIBUTING.md). - -## Status - -This README is a scaffold; recipe files are placeholders until populated. +# GitHub Copilot SDK Cookbook — Python + +This folder hosts short, practical recipes for using the GitHub Copilot SDK with Python. Each recipe is concise, copy‑pasteable, and points to fuller examples and tests. + +## Recipes + +- [SOVRINT Governed Session](sovrint-governed-session.md): Apply the SOVRINT session profile and audit helpers. +- [Error Handling](error-handling.md): Handle errors gracefully including connection failures, timeouts, and cleanup. +- [Multiple Sessions](multiple-sessions.md): Manage multiple independent conversations simultaneously. +- [Managing Local Files](managing-local-files.md): Organize files by metadata using AI-powered grouping strategies. +- [PR Visualization](pr-visualization.md): Generate interactive PR age charts using GitHub MCP Server. +- [Persisting Sessions](persisting-sessions.md): Save and resume sessions across restarts. + +## Contributing + +Add a new recipe by creating a markdown file in this folder and linking it above. Follow repository guidance in [CONTRIBUTING.md](../../CONTRIBUTING.md). + +## Status + +The SOVRINT governed-session recipe is implemented. Other listed recipes may remain scaffolds until populated. diff --git a/cookbook/python/sovrint-governed-session.md b/cookbook/python/sovrint-governed-session.md new file mode 100644 index 0000000000..08f1d97509 --- /dev/null +++ b/cookbook/python/sovrint-governed-session.md @@ -0,0 +1,96 @@ +# SOVRINT Governed Session — Python + +This recipe creates a Copilot SDK session with a bounded SOVRINT profile, a fail-closed audit sink, and a guarded custom tool. + +## Example + +```python +import asyncio + +from copilot import ( + CopilotClient, + SOVRINT_READ_ONLY_PROFILE, + apply_sovrint_profile, + define_tool, + wrap_sovrint_tool, +) + + +audit_events = [] + + +def audit_sink(event): + # Replace this with a bounded append-only sink. + # Do not add prompts, tool arguments, results, or credentials. + audit_events.append(event) + + +@define_tool(description="Return a bounded health summary for a named component") +async def inspect_state(params: dict) -> dict: + return { + "component": params["component"], + "status": "observation-only", + } + + +governed_inspect_state = wrap_sovrint_tool( + inspect_state, + SOVRINT_READ_ONLY_PROFILE, + audit_sink=audit_sink, + authorize=lambda invocation: bool(invocation.get("session_id")), +) + + +async def main(): + client = CopilotClient() + await client.start() + + config = apply_sovrint_profile( + { + "model": "gpt-5", + "tools": [governed_inspect_state], + "streaming": True, + "system_message": { + "mode": "append", + "content": ( + "Return observations without representing them as governance decisions." + ), + }, + }, + SOVRINT_READ_ONLY_PROFILE, + audit_sink=audit_sink, + ) + + session = await client.create_session(config) + response = await session.send_and_wait( + { + "prompt": ( + "Use inspect_state for component registry-alpha and summarize the observation." + ) + } + ) + + print(response.data.content) + print(f"Recorded {len(audit_events)} bounded audit events.") + await client.stop() + + +asyncio.run(main()) +``` + +## What the profile enforces + +- `read` permission requests may pass. +- `write`, `shell`, `url`, and `mcp` requests are denied. +- system-message replacement raises `ValueError` before session creation. +- any pre-existing permission handler is composed using most-restrictive-wins semantics. +- the wrapped tool records start, authorization, completion, rejection, or failure events. +- tool arguments and results are not placed in SOVRINT audit events. + +## Strict mode + +Use `SOVRINT_STRICT_PROFILE` to expose no inherited first-party tool surface and deny every permission kind. + +## Research mode + +Use `SOVRINT_RESEARCH_PROFILE` with `evaluate_permission=` to approve a narrowly scoped request after application-specific validation. Returning `None` defers to the profile, which denies by default. diff --git a/docs/sovrint/API_REFERENCE.md b/docs/sovrint/API_REFERENCE.md new file mode 100644 index 0000000000..9b72e9b0db --- /dev/null +++ b/docs/sovrint/API_REFERENCE.md @@ -0,0 +1,33 @@ +# SOVRINT™ Extension API Reference + +**Author:** Katrina Pietroniro +**Version:** 1.0 + +## Cross-Language Mapping + +| Capability | TypeScript | Python | Go | +|---|---|---|---| +| Strict profile | `SOVRINT_STRICT_PROFILE` | `SOVRINT_STRICT_PROFILE` | `SovrintStrictProfile` | +| Read-only profile | `SOVRINT_READ_ONLY_PROFILE` | `SOVRINT_READ_ONLY_PROFILE` | `SovrintReadOnlyProfile` | +| Research profile | `SOVRINT_RESEARCH_PROFILE` | `SOVRINT_RESEARCH_PROFILE` | `SovrintResearchProfile` | +| Apply profile | `applySovrintProfile` | `apply_sovrint_profile` | `ApplySovrintProfile` | +| Permission handler | `createSovrintPermissionHandler` | `create_sovrint_permission_handler` | `CreateSovrintPermissionHandler` | +| Guard custom tool | `wrapSovrintTool` | `wrap_sovrint_tool` | `WrapSovrintTool` | + +## Permission Evaluation + +The handler applies explicit denial, application evaluation, explicit allowance, profile default, any existing handler, and bounded audit recording. The most restrictive result wins. + +## Tool-Surface Modes + +- `inherit` keeps the application surface. +- `allowlist` intersects application and profile lists. +- `none` creates an explicit empty first-party tool list and clears custom-agent tool lists. + +## Custom Tools + +The wrapper records receipt, approval or rejection, and completion or failure. It excludes arguments and results from the bounded audit event. + +## Compatibility + +The helpers return normal SDK session configurations, permission handlers, and tools. Client methods and the JSON-RPC protocol remain unchanged. diff --git a/docs/sovrint/DEPLOYMENT_CHECKLIST.md b/docs/sovrint/DEPLOYMENT_CHECKLIST.md new file mode 100644 index 0000000000..a8e9780d25 --- /dev/null +++ b/docs/sovrint/DEPLOYMENT_CHECKLIST.md @@ -0,0 +1,17 @@ +# SOVRINT™ SDK Deployment Checklist + +**Author:** Katrina Pietroniro + +- pin the profile version; +- review permission kinds; +- review the effective tool list; +- keep system-message mode set to append; +- review MCP server names and skill directories; +- preserve stricter existing permission handlers; +- wrap caller-defined tools; +- configure a bounded audit sink; +- exclude prompts, responses, arguments, results, and credentials from audit events; +- distinguish local audit recording from accepted evidence; +- run TypeScript, Python, Go, schema, and documentation validation; +- confirm that applications remain unchanged until they opt in; +- review the final pull-request diff before merge. diff --git a/docs/sovrint/GOVERNANCE_INTEGRITY_EVIDENCE.md b/docs/sovrint/GOVERNANCE_INTEGRITY_EVIDENCE.md new file mode 100644 index 0000000000..d81b0c580b --- /dev/null +++ b/docs/sovrint/GOVERNANCE_INTEGRITY_EVIDENCE.md @@ -0,0 +1,139 @@ +# SOVRINT™ Governance, Integrity, and Evidence Interfaces + +**Author:** Katrina Pietroniro +**Version:** 1.0 +**Status:** Canonical extension interface guide + +## Purpose + +This document defines how a governed Copilot SDK session may interact with the SOVRINT Governance Runtime, Integrity Engine, and EvidenceGrid without collapsing their distinct authorities into the SDK process. + +## Authority Topology + +```text +APPLICATION +→ SOVRINT SDK PROFILE +→ COPILOT SDK SESSION +→ CUSTOM TOOL OR FIRST-PARTY OPERATION +→ GOVERNANCE DECISION, WHEN REQUIRED +→ EXECUTION +→ INTEGRITY REVALIDATION, WHEN REQUIRED +→ AUDIT EVENT +→ EVIDENCE ADAPTER +→ EVIDENCEGRID +``` + +## Governance Boundary + +A governed SDK helper may collect the information required for a governance request, including: + +- session identifier; +- tool name; +- permission kind; +- requested action class; +- declared scope; +- target reference; +- reversibility reference; +- intervention estimate; +- consent and review requirements; +- provenance parent. + +The helper may not issue an `ALLOW` decision on behalf of the Governance Runtime unless the application has explicitly delegated that narrow decision to a local policy evaluator. + +A local approval remains a local approval. It is not automatically a system-wide governance decision. + +## Integrity Boundary + +The SDK extension may emit integrity observations for: + +- permission-profile mismatch; +- unexpected permission kind; +- unavailable audit sink; +- custom-tool authorization failure; +- system-message replacement attempt; +- undeclared tool invocation; +- schema validation failure; +- session configuration drift; +- evidence submission failure. + +It may request Integrity Engine classification or correction, but it must not label an observation as verified malicious action without the corresponding evidence and authority. + +## Evidence Boundary + +Audit events are EvidenceGrid candidates, not EvidenceGrid blocks. + +The extension may: + +- create a bounded audit event; +- calculate or attach application-provided commitments; +- submit through an evidence adapter; +- retain the returned ledger reference or failure state. + +It may not: + +- assign EvidenceGrid sequence; +- fabricate a State Root; +- replace Proof Token `π`; +- create continuity receipts; +- represent a pending audit event as accepted evidence; +- erase rejected or quarantined submissions. + +## Recommended Decision Classes + +| SDK event | Default route | +|---|---| +| Read request under read-only profile | Local profile evaluation | +| Write request | Governance evaluation or deny | +| Shell request | Governance evaluation or deny | +| URL request | Application allowlist plus governance policy | +| MCP request | Named server and tool allowlist plus governance policy | +| Custom tool with no mutation | Local authorization and audit | +| Custom tool with mutation | Governance decision before execution | +| System-message replace attempt | Deny and record | +| Audit sink failure | Deny when fail-closed is enabled | +| Unknown permission kind | Deny, classify, and review | + +## Audit Event Lifecycle + +```text +CREATED +→ RECORDED_LOCALLY +→ SUBMISSION_PENDING +→ SUBMITTED +→ ACCEPTED OR REJECTED OR QUARANTINED OR RETRYABLE +``` + +Every status transition must preserve the prior event identifier and provenance parent. + +## Evidence Minimization + +The event should contain commitments, classifications, and bounded references rather than unrestricted content. + +Recommended fields: + +- event identifier; +- event class; +- timestamp; +- session reference; +- profile identifier and version; +- permission kind or tool name; +- decision; +- reason code; +- target class; +- scope commitment; +- governance reference; +- integrity reference; +- parent event reference; +- disclosure class; +- evidence status. + +## Canonical Separations + +```text +MODEL RESPONSE ≠ GOVERNANCE DECISION +LOCAL PROFILE APPROVAL ≠ GLOBAL AUTHORITY +AUDIT EVENT ≠ EVIDENCEGRID BLOCK +ANOMALY ≠ VERIFIED CAUSE +TOOL COMPLETION ≠ RESTORATION +LOG PRESENCE ≠ CONTINUITY PROOF +``` diff --git a/docs/sovrint/README.md b/docs/sovrint/README.md new file mode 100644 index 0000000000..f6416723bf --- /dev/null +++ b/docs/sovrint/README.md @@ -0,0 +1,140 @@ +# SOVRINT™ Governed Copilot SDK Profile + +**Author:** Katrina Pietroniro +**Framework:** SOVRINT™ +**Repository:** `SOVRINT-OG/copilot-sdk` +**Extension Version:** 1.0 +**Status:** Additive SDK extension; upstream Copilot SDK interfaces preserved + +## Purpose + +The SOVRINT™ governed profile adds an explicit control plane around Copilot SDK sessions without replacing the upstream JSON-RPC client or changing the upstream SDK protocol. + +It provides: + +- deny-by-default permission profiles; +- least-authority tool exposure; +- append-only system-message governance by default; +- custom-tool authorization wrappers; +- bounded audit events; +- governance, integrity, and EvidenceGrid interface contracts; +- simulation-safe examples; +- language-specific helpers for TypeScript, Python, and Go; +- recipes for TypeScript, Python, Go, and .NET; +- schema and CI validation. + +## Runtime Position + +```text +APPLICATION +→ SOVRINT SESSION PROFILE +→ COPILOT SDK CLIENT +→ JSON-RPC +→ COPILOT CLI SERVER +``` + +The extension does not represent itself as the GitHub Copilot service, does not replace GitHub authentication or billing, and does not modify the Copilot CLI protocol. + +## Canonical Control Sequence + +```text +DECLARE PROFILE +→ REDUCE TOOL SURFACE +→ INSTALL PERMISSION HANDLER +→ APPEND GOVERNANCE INSTRUCTIONS +→ WRAP CUSTOM TOOLS +→ CREATE SESSION +→ OBSERVE EVENTS +→ RECORD BOUNDED AUDIT EVENTS +→ ESCALATE OR CONTINUE +``` + +## Security Profiles + +### `strict` + +- denies every first-party permission unless an application-specific evaluator explicitly approves it; +- exposes no first-party tool unless the application supplies an allowlist; +- forbids system-message replacement; +- fails closed when the audit sink cannot record a permission decision. + +### `read-only` + +- permits `read` requests; +- denies `shell`, `write`, `mcp`, and `url` by default; +- forbids system-message replacement; +- supports additional application-specific restrictions. + +### `research` + +- permits `read` requests by default; +- requires an application evaluator for network, MCP, shell, and write requests; +- exposes only explicitly named tools; +- preserves audit events for decisions and custom-tool invocations. + +Profiles are templates, not universal safety guarantees. The application remains responsible for request-field interpretation, tool definitions, deployment boundaries, and audit storage. + +## Language Support + +| Surface | Support | +|---|---| +| TypeScript / Node.js | First-class helper module and unit tests | +| Python | First-class helper module and unit tests | +| Go | First-class helper module and unit tests | +| .NET | Governed-session recipe using native SDK interfaces | +| Common | JSON schemas, profiles, policies, examples, CI | + +## Repository Additions + +```text +docs/sovrint/ + README.md + SECURITY_PROFILE.md + GOVERNANCE_INTEGRITY_EVIDENCE.md +sovrint/ + manifest.yaml + profiles/ + policy/ + schemas/ + examples/ +nodejs/src/sovrint.ts +python/copilot/sovrint.py +go/sovrint.go +cookbook/*/sovrint-governed-session.md +``` + +## Authority Separation + +The SDK helper may: + +- restrict session tools; +- evaluate permission requests; +- append governance instructions; +- wrap caller-defined tools; +- emit bounded audit events; +- refuse unsafe profile combinations. + +It may not: + +- grant permissions beyond application authority; +- treat a model response as a governance decision; +- treat an audit event as EvidenceGrid acceptance; +- fabricate continuity receipts; +- replace the SOVRINT Governance Runtime, Integrity Engine, or EvidenceGrid; +- claim that deny-by-default configuration eliminates all application risk. + +## Upstream Compatibility + +The extension is additive. Existing `CopilotClient`, `SessionConfig`, custom tools, MCP configuration, provider configuration, and session APIs remain unchanged. + +Applications opt in by importing the SOVRINT helpers and applying a profile before session creation. + +## Canonical Doctrine + +```text +NO TOOL WITHOUT DECLARED PURPOSE. +NO PERMISSION WITHOUT AN EXPLICIT DECISION. +NO SYSTEM OVERRIDE WITHOUT AN EXPLICIT POLICY. +NO MUTATION WITHOUT SCOPE. +NO AUDIT CLAIM BEYOND THE EVIDENCE ACTUALLY RECORDED. +``` diff --git a/docs/sovrint/SECURITY_PROFILE.md b/docs/sovrint/SECURITY_PROFILE.md new file mode 100644 index 0000000000..07d7c23544 --- /dev/null +++ b/docs/sovrint/SECURITY_PROFILE.md @@ -0,0 +1,148 @@ +# SOVRINT™ SDK Security Profile + +**Author:** Katrina Pietroniro +**Version:** 1.0 +**Status:** Canonical extension security profile + +## Protected Surfaces + +- filesystem reads and writes; +- shell execution; +- network and URL access; +- MCP server invocation; +- custom-tool invocation; +- system-message replacement; +- skill loading; +- custom-agent tool access; +- provider credentials; +- session configuration; +- audit event integrity; +- governance and evidence references. + +## Default Position + +The SOVRINT strict profile is deny-by-default. + +A permission is approved only when: + +1. its request kind is allowed by the active profile; +2. an application evaluator does not deny it; +3. any pre-existing SDK permission handler also approves it; +4. the audit decision is recorded when fail-closed auditing is enabled. + +## Decision Precedence + +```text +EXPLICIT DENY +→ APPLICATION EVALUATOR +→ PROFILE ALLOW +→ PROFILE DEFAULT +→ EXISTING HANDLER +→ FINAL DECISION +``` + +The most restrictive result wins. + +## System Message Boundary + +The upstream SDK supports append mode and replace mode. Replace mode removes SDK-managed guardrails. The SOVRINT profiles therefore forbid replace mode by default. + +Applications may opt out only through an explicit custom profile. Doing so is a deployment decision, not a safe default. + +## Tool Surface Reduction + +`availableTools` is preferred over a broad tool surface. When both an application config and a SOVRINT profile specify an allowlist, the effective set is the intersection. + +`excludedTools` is additive. Profile exclusions and application exclusions are combined. + +Custom tools remain caller code. Wrapping a custom tool adds authorization and audit boundaries but does not make the tool intrinsically safe. + +## Permission Kinds + +The current SDK permission request kinds are: + +- `read` +- `write` +- `shell` +- `url` +- `mcp` + +The extension treats unknown kinds as denied. + +## Audit Events + +The extension emits bounded events for: + +- permission decision; +- custom-tool start; +- custom-tool approval or rejection; +- custom-tool completion; +- custom-tool failure; +- profile application; +- policy violation. + +Audit events must not contain: + +- provider API keys; +- bearer tokens; +- unrestricted prompts or responses; +- raw file contents; +- full shell output; +- private witnesses; +- secret environment variables. + +## Failure-Safe Rules + +```text +UNKNOWN PERMISSION KIND → DENY +AUDIT FAILURE WITH FAIL-CLOSED ENABLED → DENY +SYSTEM MESSAGE REPLACE UNDER FORBIDDEN PROFILE → THROW +EMPTY EFFECTIVE TOOL ALLOWLIST → EXPOSE NO FIRST-PARTY TOOLS +CUSTOM AUTHORIZER ERROR → REJECT TOOL INVOCATION +EXISTING PERMISSION HANDLER DENIAL → PRESERVE DENIAL +``` + +## Threats Addressed + +### Accidental broad permissions + +Mitigated through deny-by-default profiles and explicit allowlists. + +### Permission-handler bypass + +Mitigated by composing profile and application handlers using most-restrictive-wins semantics. + +### System prompt replacement + +Mitigated by rejecting replace mode unless a custom profile explicitly permits it. + +### Tool confusion + +Mitigated through exact tool names, wrapped authorization, invocation events, and bounded telemetry. + +### Audit exfiltration + +Mitigated by bounded event fields and explicit prohibition of secrets and unrestricted content. + +### Profile drift + +Mitigated through versioned JSON profiles, a common schema, manifest commitments, and CI validation. + +## Residual Risks + +- the SDK server may introduce new permission fields or kinds; +- an application evaluator may be incorrect; +- a tool may perform broader actions than its name or schema suggests; +- approved reads may still expose sensitive information; +- URL and MCP requests can create external disclosure paths; +- audit sinks can be unavailable, compromised, or incomplete; +- system-message append content is not an enforcement boundary by itself; +- model behavior is not equivalent to policy enforcement; +- technical controls do not establish legal authority or valid consent. + +## Canonical Rule + +```text +THE PROFILE REDUCES AUTHORITY. +IT DOES NOT CREATE AUTHORITY. +``` diff --git a/go/sovrint.go b/go/sovrint.go new file mode 100644 index 0000000000..f41310586f --- /dev/null +++ b/go/sovrint.go @@ -0,0 +1,522 @@ +package copilot + +import ( + "fmt" + "sync/atomic" + "time" +) + +// SovrintPermissionDecision is a bounded permission outcome used by an +// application evaluator before the SDK server receives a final decision. +type SovrintPermissionDecision string + +const ( + SovrintApprove SovrintPermissionDecision = "approve" + SovrintDeny SovrintPermissionDecision = "deny" +) + +// SovrintSecurityProfile defines a versioned governed-session profile. +type SovrintSecurityProfile struct { + ProfileID string + Version string + Description string + DefaultDecision SovrintPermissionDecision + AllowKinds []string + DenyKinds []string + ToolSurfaceMode string // inherit, allowlist, none + AvailableTools []string + ExcludedTools []string + AllowedMCPServers []string + AllowedSkillDirectories []string + DisabledSkills []string + ForbidSystemMessageReplace bool + FailClosedOnAuditError bool + AuditEnabled bool + SystemMessageAppend string +} + +// SovrintAuditEvent is a bounded event. It intentionally excludes tool +// arguments, model responses, file contents, provider credentials, and raw +// command output. +type SovrintAuditEvent struct { + SchemaVersion string `json:"schemaVersion"` + EventID string `json:"eventId"` + ParentEventID string `json:"parentEventId,omitempty"` + EventClass string `json:"eventClass"` + TimestampUTC string `json:"timestampUtc"` + ProfileID string `json:"profileId"` + ProfileVersion string `json:"profileVersion"` + SessionID string `json:"sessionId"` + ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` + PermissionKind string `json:"permissionKind,omitempty"` + Decision string `json:"decision"` + ReasonCode string `json:"reasonCode"` + DisclosureClass string `json:"disclosureClass"` + EvidenceStatus string `json:"evidenceStatus"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// SovrintAuditSink records a bounded audit event. +type SovrintAuditSink func(event SovrintAuditEvent) error + +// SovrintPermissionEvaluator returns a decision or an empty decision to defer +// to the profile. +type SovrintPermissionEvaluator func( + request PermissionRequest, + invocation PermissionInvocation, +) (SovrintPermissionDecision, error) + +// SovrintPermissionHandlerOptions configures permission composition. +type SovrintPermissionHandlerOptions struct { + AuditSink SovrintAuditSink + Evaluate SovrintPermissionEvaluator + Downstream PermissionHandler +} + +// SovrintApplyProfileOptions configures profile application. +type SovrintApplyProfileOptions struct { + AuditSink SovrintAuditSink + EvaluatePermission SovrintPermissionEvaluator +} + +// SovrintToolAuthorizer approves a caller-defined tool invocation. +type SovrintToolAuthorizer func(invocation ToolInvocation) (bool, error) + +// SovrintToolGuardOptions configures a guarded custom tool. +type SovrintToolGuardOptions struct { + Profile SovrintSecurityProfile + AuditSink SovrintAuditSink + Authorize SovrintToolAuthorizer +} + +const SovrintSystemAppend = "Operate under a bounded SOVRINT governed-session profile. Use only explicitly exposed tools and declared authority. Treat observations, inferences, recommendations, governance decisions, integrity findings, and accepted evidence as distinct classes. Do not claim approval, verification, restoration, or EvidenceGrid acceptance without an explicit external result." + +var SovrintStrictProfile = SovrintSecurityProfile{ + ProfileID: "sovrint.strict", + Version: "1.0", + Description: "Deny every permission and expose no inherited first-party tools.", + DefaultDecision: SovrintDeny, + DenyKinds: []string{"read", "write", "shell", "url", "mcp"}, + ToolSurfaceMode: "none", + AllowedMCPServers: []string{}, + AllowedSkillDirectories: []string{}, + ForbidSystemMessageReplace: true, + FailClosedOnAuditError: true, + AuditEnabled: true, + SystemMessageAppend: SovrintSystemAppend, +} + +var SovrintReadOnlyProfile = SovrintSecurityProfile{ + ProfileID: "sovrint.read-only", + Version: "1.0", + Description: "Permit reads while denying mutating and external permission kinds.", + DefaultDecision: SovrintDeny, + AllowKinds: []string{"read"}, + DenyKinds: []string{"write", "shell", "url", "mcp"}, + ToolSurfaceMode: "inherit", + AllowedMCPServers: []string{}, + AllowedSkillDirectories: []string{}, + ForbidSystemMessageReplace: true, + FailClosedOnAuditError: true, + AuditEnabled: true, + SystemMessageAppend: SovrintSystemAppend + " Operate in read-only mode.", +} + +var SovrintResearchProfile = SovrintSecurityProfile{ + ProfileID: "sovrint.research", + Version: "1.0", + Description: "Permit reads and require an evaluator for every other permission kind.", + DefaultDecision: SovrintDeny, + AllowKinds: []string{"read"}, + DenyKinds: []string{}, + ToolSurfaceMode: "inherit", + AllowedMCPServers: []string{}, + AllowedSkillDirectories: []string{}, + ForbidSystemMessageReplace: true, + FailClosedOnAuditError: false, + AuditEnabled: true, + SystemMessageAppend: SovrintSystemAppend + " Separate research observations from verified findings.", +} + +var sovrintAuditSequence uint64 + +func newSovrintAuditEvent( + profile SovrintSecurityProfile, + eventClass string, + sessionID string, + decision string, + reasonCode string, +) SovrintAuditEvent { + sequence := atomic.AddUint64(&sovrintAuditSequence, 1) + return SovrintAuditEvent{ + SchemaVersion: "1.0", + EventID: fmt.Sprintf("sovrint-%d-%d", time.Now().UnixMilli(), sequence), + EventClass: eventClass, + TimestampUTC: time.Now().UTC().Format(time.RFC3339Nano), + ProfileID: profile.ProfileID, + ProfileVersion: profile.Version, + SessionID: sessionID, + Decision: decision, + ReasonCode: reasonCode, + DisclosureClass: "INTERNAL", + EvidenceStatus: "NOT_SUBMITTED", + } +} + +func emitSovrintAudit( + profile SovrintSecurityProfile, + sink SovrintAuditSink, + event SovrintAuditEvent, +) bool { + if !profile.AuditEnabled { + return true + } + if sink == nil { + return !profile.FailClosedOnAuditError + } + if err := sink(event); err != nil { + return !profile.FailClosedOnAuditError + } + return true +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func sovrintPermissionResult( + approved bool, + profile SovrintSecurityProfile, + reasonCode string, +) PermissionRequestResult { + kind := "denied-by-rules" + if approved { + kind = "approved" + } + return PermissionRequestResult{ + Kind: kind, + Rules: []interface{}{ + map[string]interface{}{ + "source": "sovrint", + "profileId": profile.ProfileID, + "profileVersion": profile.Version, + "reasonCode": reasonCode, + }, + }, + } +} + +func evaluateSovrintPermission( + profile SovrintSecurityProfile, + request PermissionRequest, + invocation PermissionInvocation, + evaluator SovrintPermissionEvaluator, +) (bool, string) { + if request.Kind == "" { + return false, "UNKNOWN_PERMISSION_KIND" + } + if containsString(profile.DenyKinds, request.Kind) { + return false, "PROFILE_EXPLICIT_DENY" + } + if evaluator != nil { + decision, err := evaluator(request, invocation) + if err != nil { + return false, "APPLICATION_EVALUATOR_FAILED" + } + if decision == SovrintApprove { + return true, "APPLICATION_EVALUATOR_APPROVED" + } + if decision == SovrintDeny { + return false, "APPLICATION_EVALUATOR_DENIED" + } + } + if containsString(profile.AllowKinds, request.Kind) { + return true, "PROFILE_EXPLICIT_ALLOW" + } + if profile.DefaultDecision == SovrintApprove { + return true, "PROFILE_DEFAULT_ALLOW" + } + return false, "PROFILE_DEFAULT_DENY" +} + +// CreateSovrintPermissionHandler composes the profile, application evaluator, +// and any existing handler using most-restrictive-wins semantics. +func CreateSovrintPermissionHandler( + profile SovrintSecurityProfile, + options SovrintPermissionHandlerOptions, +) PermissionHandler { + return func( + request PermissionRequest, + invocation PermissionInvocation, + ) (PermissionRequestResult, error) { + approved, reasonCode := evaluateSovrintPermission( + profile, + request, + invocation, + options.Evaluate, + ) + + if approved && options.Downstream != nil { + downstreamResult, err := options.Downstream(request, invocation) + if err != nil { + approved = false + reasonCode = "DOWNSTREAM_HANDLER_FAILED" + } else if downstreamResult.Kind != "approved" { + approved = false + reasonCode = "DOWNSTREAM_HANDLER_DENIED" + } + } + + event := newSovrintAuditEvent( + profile, + "PERMISSION_DECISION", + invocation.SessionID, + map[bool]string{true: "APPROVED", false: "DENIED"}[approved], + reasonCode, + ) + event.ToolCallID = request.ToolCallID + event.PermissionKind = request.Kind + + if !emitSovrintAudit(profile, options.AuditSink, event) && approved { + return sovrintPermissionResult(false, profile, "AUDIT_SINK_UNAVAILABLE"), nil + } + return sovrintPermissionResult(approved, profile, reasonCode), nil + } +} + +func copyStrings(values []string) []string { + if values == nil { + return nil + } + return append([]string{}, values...) +} + +func intersectStrings(left []string, right []string) []string { + allowed := make(map[string]struct{}, len(right)) + for _, value := range right { + allowed[value] = struct{}{} + } + result := make([]string, 0) + for _, value := range left { + if _, ok := allowed[value]; ok { + result = append(result, value) + } + } + return result +} + +func mergeUniqueStrings(left []string, right []string) []string { + seen := make(map[string]struct{}, len(left)+len(right)) + result := make([]string, 0, len(left)+len(right)) + for _, value := range append(copyStrings(left), right...) { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +// ApplySovrintProfile returns a copied SessionConfig constrained by the profile. +func ApplySovrintProfile( + config *SessionConfig, + profile SovrintSecurityProfile, + options SovrintApplyProfileOptions, +) (*SessionConfig, error) { + if config == nil { + config = &SessionConfig{} + } + result := *config + result.AvailableTools = copyStrings(config.AvailableTools) + result.ExcludedTools = copyStrings(config.ExcludedTools) + result.SkillDirectories = copyStrings(config.SkillDirectories) + result.DisabledSkills = copyStrings(config.DisabledSkills) + + if profile.ToolSurfaceMode == "none" { + result.AvailableTools = []string{} + } else if profile.ToolSurfaceMode == "allowlist" { + if config.AvailableTools == nil { + result.AvailableTools = copyStrings(profile.AvailableTools) + } else { + result.AvailableTools = intersectStrings(config.AvailableTools, profile.AvailableTools) + } + } + result.ExcludedTools = mergeUniqueStrings(config.ExcludedTools, profile.ExcludedTools) + + if config.SystemMessage != nil && config.SystemMessage.Mode == "replace" { + if profile.ForbidSystemMessageReplace { + return nil, fmt.Errorf( + "SOVRINT profile %q forbids system-message replacement", + profile.ProfileID, + ) + } + copied := *config.SystemMessage + result.SystemMessage = &copied + } else { + existing := "" + if config.SystemMessage != nil { + existing = config.SystemMessage.Content + } + content := profile.SystemMessageAppend + if existing != "" && content != "" { + content = existing + "\n\n" + content + } else if existing != "" { + content = existing + } + if content != "" { + result.SystemMessage = &SystemMessageConfig{Mode: "append", Content: content} + } + } + + result.OnPermissionRequest = CreateSovrintPermissionHandler( + profile, + SovrintPermissionHandlerOptions{ + AuditSink: options.AuditSink, + Evaluate: options.EvaluatePermission, + Downstream: config.OnPermissionRequest, + }, + ) + + if config.CustomAgents != nil { + result.CustomAgents = append([]CustomAgentConfig{}, config.CustomAgents...) + if profile.ToolSurfaceMode != "inherit" { + for index := range result.CustomAgents { + if profile.ToolSurfaceMode == "none" { + result.CustomAgents[index].Tools = []string{} + } else { + tools := result.CustomAgents[index].Tools + if tools == nil { + tools = profile.AvailableTools + } + result.CustomAgents[index].Tools = intersectStrings(tools, profile.AvailableTools) + } + } + } + } + + if config.MCPServers != nil { + allowed := make(map[string]struct{}, len(profile.AllowedMCPServers)) + for _, name := range profile.AllowedMCPServers { + allowed[name] = struct{}{} + } + result.MCPServers = make(map[string]MCPServerConfig) + for name, server := range config.MCPServers { + if _, ok := allowed[name]; ok { + result.MCPServers[name] = server + } + } + } + + if config.SkillDirectories != nil { + result.SkillDirectories = intersectStrings( + config.SkillDirectories, + profile.AllowedSkillDirectories, + ) + } + result.DisabledSkills = mergeUniqueStrings(config.DisabledSkills, profile.DisabledSkills) + return &result, nil +} + +func rejectedSovrintToolResult(reasonCode string) ToolResult { + return ToolResult{ + TextResultForLLM: "The governed tool invocation was not authorized.", + ResultType: "rejected", + Error: reasonCode, + ToolTelemetry: map[string]interface{}{ + "source": "sovrint", + "reasonCode": reasonCode, + }, + } +} + +// WrapSovrintTool adds an application authorization gate and bounded audit +// events around a caller-defined tool. +func WrapSovrintTool(tool Tool, options SovrintToolGuardOptions) Tool { + original := tool.Handler + tool.Handler = func(invocation ToolInvocation) (ToolResult, error) { + started := newSovrintAuditEvent( + options.Profile, + "TOOL_INVOCATION_STARTED", + invocation.SessionID, + "PENDING", + "TOOL_INVOCATION_RECEIVED", + ) + started.ToolCallID = invocation.ToolCallID + started.ToolName = tool.Name + if !emitSovrintAudit(options.Profile, options.AuditSink, started) { + return rejectedSovrintToolResult("AUDIT_SINK_UNAVAILABLE"), nil + } + + if options.Authorize != nil { + authorized, err := options.Authorize(invocation) + if err != nil || !authorized { + rejected := newSovrintAuditEvent( + options.Profile, + "TOOL_INVOCATION_REJECTED", + invocation.SessionID, + "REJECTED", + "CUSTOM_TOOL_AUTHORIZATION_DENIED", + ) + rejected.ParentEventID = started.EventID + rejected.ToolCallID = invocation.ToolCallID + rejected.ToolName = tool.Name + emitSovrintAudit(options.Profile, options.AuditSink, rejected) + return rejectedSovrintToolResult("CUSTOM_TOOL_AUTHORIZATION_DENIED"), nil + } + } + + approved := newSovrintAuditEvent( + options.Profile, + "TOOL_INVOCATION_APPROVED", + invocation.SessionID, + "APPROVED", + "CUSTOM_TOOL_AUTHORIZED", + ) + approved.ParentEventID = started.EventID + approved.ToolCallID = invocation.ToolCallID + approved.ToolName = tool.Name + emitSovrintAudit(options.Profile, options.AuditSink, approved) + + result, err := original(invocation) + if err != nil { + failed := newSovrintAuditEvent( + options.Profile, + "TOOL_INVOCATION_FAILED", + invocation.SessionID, + "FAILED", + "CUSTOM_TOOL_FAILED", + ) + failed.ParentEventID = started.EventID + failed.ToolCallID = invocation.ToolCallID + failed.ToolName = tool.Name + emitSovrintAudit(options.Profile, options.AuditSink, failed) + return ToolResult{ + TextResultForLLM: "The governed tool invocation failed.", + ResultType: "failure", + Error: err.Error(), + ToolTelemetry: map[string]interface{}{"source": "sovrint"}, + }, nil + } + + completed := newSovrintAuditEvent( + options.Profile, + "TOOL_INVOCATION_COMPLETED", + invocation.SessionID, + "RECORDED", + "CUSTOM_TOOL_COMPLETED", + ) + completed.ParentEventID = started.EventID + completed.ToolCallID = invocation.ToolCallID + completed.ToolName = tool.Name + emitSovrintAudit(options.Profile, options.AuditSink, completed) + return result, nil + } + return tool +} diff --git a/go/sovrint_audit_event.go b/go/sovrint_audit_event.go new file mode 100644 index 0000000000..7843b712dd --- /dev/null +++ b/go/sovrint_audit_event.go @@ -0,0 +1,23 @@ +package copilot + +// SovrintAuditEvent is the bounded event envelope for governed SDK activity. +type SovrintAuditEvent struct { + SchemaVersion string `json:"schemaVersion"` + EventID string `json:"eventId"` + ParentEventID string `json:"parentEventId,omitempty"` + EventClass string `json:"eventClass"` + TimestampUTC string `json:"timestampUtc"` + ProfileID string `json:"profileId"` + ProfileVersion string `json:"profileVersion"` + SessionID string `json:"sessionId"` + ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` + PermissionKind string `json:"permissionKind,omitempty"` + Decision string `json:"decision"` + ReasonCode string `json:"reasonCode"` + DisclosureClass string `json:"disclosureClass"` + EvidenceStatus string `json:"evidenceStatus"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type SovrintAuditSink func(SovrintAuditEvent) error diff --git a/go/sovrint_audit_runtime.go b/go/sovrint_audit_runtime.go new file mode 100644 index 0000000000..0a1ddca77c --- /dev/null +++ b/go/sovrint_audit_runtime.go @@ -0,0 +1,49 @@ +package copilot + +import ( + "fmt" + "sync/atomic" + "time" +) + +var sovrintAuditSequence uint64 + +func newSovrintAuditEvent( + profile SovrintSecurityProfile, + eventClass string, + sessionID string, + decision string, + reasonCode string, +) SovrintAuditEvent { + sequence := atomic.AddUint64(&sovrintAuditSequence, 1) + return SovrintAuditEvent{ + SchemaVersion: "1.0", + EventID: fmt.Sprintf("sovrint-%d-%d", time.Now().UnixMilli(), sequence), + EventClass: eventClass, + TimestampUTC: time.Now().UTC().Format(time.RFC3339Nano), + ProfileID: profile.ProfileID, + ProfileVersion: profile.Version, + SessionID: sessionID, + Decision: decision, + ReasonCode: reasonCode, + DisclosureClass: "INTERNAL", + EvidenceStatus: "NOT_SUBMITTED", + } +} + +func emitSovrintAudit( + profile SovrintSecurityProfile, + sink SovrintAuditSink, + event SovrintAuditEvent, +) bool { + if !profile.AuditEnabled { + return true + } + if sink == nil { + return !profile.FailClosedOnAuditError + } + if err := sink(event); err != nil { + return !profile.FailClosedOnAuditError + } + return true +} diff --git a/go/sovrint_permission_contract.go b/go/sovrint_permission_contract.go new file mode 100644 index 0000000000..b8bb09fdc1 --- /dev/null +++ b/go/sovrint_permission_contract.go @@ -0,0 +1,12 @@ +package copilot + +type SovrintPermissionEvaluator func( + PermissionRequest, + PermissionInvocation, +) (SovrintPermissionDecision, error) + +type SovrintPermissionHandlerOptions struct { + AuditSink SovrintAuditSink + Evaluate SovrintPermissionEvaluator + Downstream PermissionHandler +} diff --git a/go/sovrint_permission_eval.go b/go/sovrint_permission_eval.go new file mode 100644 index 0000000000..f8dd9f2ca0 --- /dev/null +++ b/go/sovrint_permission_eval.go @@ -0,0 +1,43 @@ +package copilot + +func containsSovrintValue(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func evaluateSovrintPermission( + profile SovrintSecurityProfile, + request PermissionRequest, + invocation PermissionInvocation, + evaluator SovrintPermissionEvaluator, +) (bool, string) { + if request.Kind == "" { + return false, "UNKNOWN_PERMISSION_KIND" + } + if containsSovrintValue(profile.DenyKinds, request.Kind) { + return false, "PROFILE_EXPLICIT_DENY" + } + if evaluator != nil { + decision, err := evaluator(request, invocation) + if err != nil { + return false, "APPLICATION_EVALUATOR_FAILED" + } + if decision == SovrintApprove { + return true, "APPLICATION_EVALUATOR_APPROVED" + } + if decision == SovrintDeny { + return false, "APPLICATION_EVALUATOR_DENIED" + } + } + if containsSovrintValue(profile.AllowKinds, request.Kind) { + return true, "PROFILE_EXPLICIT_ALLOW" + } + if profile.DefaultDecision == SovrintApprove { + return true, "PROFILE_DEFAULT_ALLOW" + } + return false, "PROFILE_DEFAULT_DENY" +} diff --git a/go/sovrint_permission_handler.go b/go/sovrint_permission_handler.go new file mode 100644 index 0000000000..f214913cae --- /dev/null +++ b/go/sovrint_permission_handler.go @@ -0,0 +1,45 @@ +package copilot + +// CreateSovrintPermissionHandler composes permission authorities conservatively. +func CreateSovrintPermissionHandler( + profile SovrintSecurityProfile, + options SovrintPermissionHandlerOptions, +) PermissionHandler { + return func( + request PermissionRequest, + invocation PermissionInvocation, + ) (PermissionRequestResult, error) { + approved, reason := evaluateSovrintPermission( + profile, + request, + invocation, + options.Evaluate, + ) + if approved && options.Downstream != nil { + downstream, err := options.Downstream(request, invocation) + if err != nil { + approved, reason = false, "DOWNSTREAM_HANDLER_FAILED" + } else if downstream.Kind != "approved" { + approved, reason = false, "DOWNSTREAM_HANDLER_DENIED" + } + } + + decision := "DENIED" + if approved { + decision = "APPROVED" + } + event := newSovrintAuditEvent( + profile, + "PERMISSION_DECISION", + invocation.SessionID, + decision, + reason, + ) + event.ToolCallID = request.ToolCallID + event.PermissionKind = request.Kind + if !emitSovrintAudit(profile, options.AuditSink, event) && approved { + return sovrintPermissionResult(false, profile, "AUDIT_SINK_UNAVAILABLE"), nil + } + return sovrintPermissionResult(approved, profile, reason), nil + } +} diff --git a/go/sovrint_permission_result.go b/go/sovrint_permission_result.go new file mode 100644 index 0000000000..dd9b2160e8 --- /dev/null +++ b/go/sovrint_permission_result.go @@ -0,0 +1,23 @@ +package copilot + +func sovrintPermissionResult( + approved bool, + profile SovrintSecurityProfile, + reasonCode string, +) PermissionRequestResult { + kind := "denied-by-rules" + if approved { + kind = "approved" + } + return PermissionRequestResult{ + Kind: kind, + Rules: []interface{}{ + map[string]interface{}{ + "source": "sovrint", + "profileId": profile.ProfileID, + "profileVersion": profile.Version, + "reasonCode": reasonCode, + }, + }, + } +} diff --git a/go/sovrint_profile.go b/go/sovrint_profile.go new file mode 100644 index 0000000000..c271f3d250 --- /dev/null +++ b/go/sovrint_profile.go @@ -0,0 +1,71 @@ +package copilot + +// SovrintPermissionDecision is a bounded application decision. +type SovrintPermissionDecision string + +const ( + SovrintApprove SovrintPermissionDecision = "approve" + SovrintDeny SovrintPermissionDecision = "deny" +) + +// SovrintSecurityProfile defines a versioned governed-session policy. +type SovrintSecurityProfile struct { + ProfileID string + Version string + Description string + DefaultDecision SovrintPermissionDecision + AllowKinds []string + DenyKinds []string + ToolSurfaceMode string + AvailableTools []string + ExcludedTools []string + AllowedMCPServers []string + AllowedSkillDirectories []string + DisabledSkills []string + ForbidSystemMessageReplace bool + FailClosedOnAuditError bool + AuditEnabled bool + SystemMessageAppend string +} + +// SovrintSystemAppend is the canonical governed-session instruction layer. +const SovrintSystemAppend = "Operate under a bounded SOVRINT governed-session profile. Use only explicitly exposed tools and declared authority. Keep observations, inferences, recommendations, governance decisions, integrity findings, and accepted evidence distinct. Do not claim approval, verification, restoration, or EvidenceGrid acceptance without an explicit external result." + +var SovrintStrictProfile = SovrintSecurityProfile{ + ProfileID: "sovrint.strict", + Version: "1.0", + Description: "Deny every permission and expose no inherited first-party tools.", + DefaultDecision: SovrintDeny, + DenyKinds: []string{"read", "write", "shell", "url", "mcp"}, + ToolSurfaceMode: "none", + ForbidSystemMessageReplace: true, + FailClosedOnAuditError: true, + AuditEnabled: true, + SystemMessageAppend: SovrintSystemAppend, +} + +var SovrintReadOnlyProfile = SovrintSecurityProfile{ + ProfileID: "sovrint.read-only", + Version: "1.0", + Description: "Permit reads while denying mutating and external permission kinds.", + DefaultDecision: SovrintDeny, + AllowKinds: []string{"read"}, + DenyKinds: []string{"write", "shell", "url", "mcp"}, + ToolSurfaceMode: "inherit", + ForbidSystemMessageReplace: true, + FailClosedOnAuditError: true, + AuditEnabled: true, + SystemMessageAppend: SovrintSystemAppend + " Operate in read-only mode.", +} + +var SovrintResearchProfile = SovrintSecurityProfile{ + ProfileID: "sovrint.research", + Version: "1.0", + Description: "Permit reads and defer other permission kinds to an application evaluator.", + DefaultDecision: SovrintDeny, + AllowKinds: []string{"read"}, + ToolSurfaceMode: "inherit", + ForbidSystemMessageReplace: true, + AuditEnabled: true, + SystemMessageAppend: SovrintSystemAppend + " Separate research observations from verified findings.", +} diff --git a/go/sovrint_session_apply.go b/go/sovrint_session_apply.go new file mode 100644 index 0000000000..6521565fd0 --- /dev/null +++ b/go/sovrint_session_apply.go @@ -0,0 +1,82 @@ +package copilot + +import "fmt" + +type SovrintApplyProfileOptions struct { + AuditSink SovrintAuditSink + EvaluatePermission SovrintPermissionEvaluator +} + +// ApplySovrintProfile projects a governed profile onto a copied SDK configuration. +func ApplySovrintProfile( + config *SessionConfig, + profile SovrintSecurityProfile, + options SovrintApplyProfileOptions, +) (*SessionConfig, error) { + if config == nil { + config = &SessionConfig{} + } + result := *config + result.AvailableTools = copySovrintStrings(config.AvailableTools) + result.ExcludedTools = copySovrintStrings(config.ExcludedTools) + result.SkillDirectories = copySovrintStrings(config.SkillDirectories) + result.DisabledSkills = copySovrintStrings(config.DisabledSkills) + + if profile.ToolSurfaceMode == "none" { + result.AvailableTools = []string{} + } else if profile.ToolSurfaceMode == "allowlist" { + if config.AvailableTools == nil { + result.AvailableTools = copySovrintStrings(profile.AvailableTools) + } else { + result.AvailableTools = intersectSovrintStrings( + config.AvailableTools, + profile.AvailableTools, + ) + } + } + result.ExcludedTools = mergeSovrintStrings(config.ExcludedTools, profile.ExcludedTools) + + if config.SystemMessage != nil && config.SystemMessage.Mode == "replace" { + if profile.ForbidSystemMessageReplace { + return nil, fmt.Errorf( + "SOVRINT profile %q forbids system-message replacement", + profile.ProfileID, + ) + } + copied := *config.SystemMessage + result.SystemMessage = &copied + } else { + appendSovrintSystemMessage(config, &result, profile) + } + + result.OnPermissionRequest = CreateSovrintPermissionHandler( + profile, + SovrintPermissionHandlerOptions{ + AuditSink: options.AuditSink, + Evaluate: options.EvaluatePermission, + Downstream: config.OnPermissionRequest, + }, + ) + constrainSovrintSessionSurfaces(config, &result, profile) + return &result, nil +} + +func appendSovrintSystemMessage( + config *SessionConfig, + result *SessionConfig, + profile SovrintSecurityProfile, +) { + existing := "" + if config.SystemMessage != nil { + existing = config.SystemMessage.Content + } + content := profile.SystemMessageAppend + if existing != "" && content != "" { + content = existing + "\n\n" + content + } else if existing != "" { + content = existing + } + if content != "" { + result.SystemMessage = &SystemMessageConfig{Mode: "append", Content: content} + } +} diff --git a/go/sovrint_session_surface.go b/go/sovrint_session_surface.go new file mode 100644 index 0000000000..7c6d5867e2 --- /dev/null +++ b/go/sovrint_session_surface.go @@ -0,0 +1,47 @@ +package copilot + +func constrainSovrintSessionSurfaces( + config *SessionConfig, + result *SessionConfig, + profile SovrintSecurityProfile, +) { + if config.CustomAgents != nil { + result.CustomAgents = append([]CustomAgentConfig{}, config.CustomAgents...) + if profile.ToolSurfaceMode != "inherit" { + for index := range result.CustomAgents { + if profile.ToolSurfaceMode == "none" { + result.CustomAgents[index].Tools = []string{} + continue + } + tools := result.CustomAgents[index].Tools + if tools == nil { + tools = profile.AvailableTools + } + result.CustomAgents[index].Tools = intersectSovrintStrings( + tools, + profile.AvailableTools, + ) + } + } + } + + if config.MCPServers != nil { + allowed := make(map[string]struct{}, len(profile.AllowedMCPServers)) + for _, name := range profile.AllowedMCPServers { + allowed[name] = struct{}{} + } + result.MCPServers = make(map[string]MCPServerConfig) + for name, server := range config.MCPServers { + if _, ok := allowed[name]; ok { + result.MCPServers[name] = server + } + } + } + if config.SkillDirectories != nil { + result.SkillDirectories = intersectSovrintStrings( + config.SkillDirectories, + profile.AllowedSkillDirectories, + ) + } + result.DisabledSkills = mergeSovrintStrings(config.DisabledSkills, profile.DisabledSkills) +} diff --git a/go/sovrint_slice.go b/go/sovrint_slice.go new file mode 100644 index 0000000000..6243b4f303 --- /dev/null +++ b/go/sovrint_slice.go @@ -0,0 +1,35 @@ +package copilot + +func copySovrintStrings(values []string) []string { + if values == nil { + return nil + } + return append([]string{}, values...) +} + +func intersectSovrintStrings(left []string, right []string) []string { + allowed := make(map[string]struct{}, len(right)) + for _, value := range right { + allowed[value] = struct{}{} + } + result := make([]string, 0) + for _, value := range left { + if _, ok := allowed[value]; ok { + result = append(result, value) + } + } + return result +} + +func mergeSovrintStrings(left []string, right []string) []string { + seen := make(map[string]struct{}, len(left)+len(right)) + result := make([]string, 0, len(left)+len(right)) + for _, value := range append(copySovrintStrings(left), right...) { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} diff --git a/go/sovrint_test.go b/go/sovrint_test.go new file mode 100644 index 0000000000..ae620ca0ab --- /dev/null +++ b/go/sovrint_test.go @@ -0,0 +1,127 @@ +package copilot + +import "testing" + +func auditOK(event SovrintAuditEvent) error { return nil } + +func TestSovrintPermissionProfiles(t *testing.T) { + strict := CreateSovrintPermissionHandler( + SovrintStrictProfile, + SovrintPermissionHandlerOptions{AuditSink: auditOK}, + ) + result, err := strict( + PermissionRequest{Kind: "read"}, + PermissionInvocation{SessionID: "session-1"}, + ) + if err != nil || result.Kind != "denied-by-rules" { + t.Fatalf("strict profile should deny read: %#v %v", result, err) + } + + readOnly := CreateSovrintPermissionHandler( + SovrintReadOnlyProfile, + SovrintPermissionHandlerOptions{AuditSink: auditOK}, + ) + read, _ := readOnly( + PermissionRequest{Kind: "read"}, + PermissionInvocation{SessionID: "session-2"}, + ) + write, _ := readOnly( + PermissionRequest{Kind: "write"}, + PermissionInvocation{SessionID: "session-2"}, + ) + if read.Kind != "approved" || write.Kind != "denied-by-rules" { + t.Fatalf("unexpected read-only decisions: read=%#v write=%#v", read, write) + } +} + +func TestSovrintResearchEvaluator(t *testing.T) { + handler := CreateSovrintPermissionHandler( + SovrintResearchProfile, + SovrintPermissionHandlerOptions{ + AuditSink: auditOK, + Evaluate: func( + request PermissionRequest, + invocation PermissionInvocation, + ) (SovrintPermissionDecision, error) { + if request.Kind == "url" { + return SovrintApprove, nil + } + return "", nil + }, + }, + ) + result, err := handler( + PermissionRequest{Kind: "url"}, + PermissionInvocation{SessionID: "session-3"}, + ) + if err != nil || result.Kind != "approved" { + t.Fatalf("research evaluator should approve: %#v %v", result, err) + } +} + +func TestApplySovrintProfile(t *testing.T) { + _, err := ApplySovrintProfile( + &SessionConfig{ + SystemMessage: &SystemMessageConfig{Mode: "replace", Content: "replacement"}, + }, + SovrintReadOnlyProfile, + SovrintApplyProfileOptions{AuditSink: auditOK}, + ) + if err == nil { + t.Fatal("expected replacement rejection") + } + + config, err := ApplySovrintProfile( + &SessionConfig{ + AvailableTools: []string{"read_file", "write_file"}, + SkillDirectories: []string{"skills"}, + CustomAgents: []CustomAgentConfig{ + {Name: "worker", Prompt: "work"}, + }, + }, + SovrintStrictProfile, + SovrintApplyProfileOptions{AuditSink: auditOK}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if config.AvailableTools == nil || len(config.AvailableTools) != 0 { + t.Fatalf("expected explicit empty tool surface: %#v", config.AvailableTools) + } + if len(config.SkillDirectories) != 0 { + t.Fatalf("expected no skill directories: %#v", config.SkillDirectories) + } + if len(config.CustomAgents) != 1 || config.CustomAgents[0].Tools == nil { + t.Fatalf("expected constrained custom agent: %#v", config.CustomAgents) + } +} + +func TestWrapSovrintTool(t *testing.T) { + called := false + tool := Tool{ + Name: "inspect_state", + Handler: func(invocation ToolInvocation) (ToolResult, error) { + called = true + return ToolResult{TextResultForLLM: "ok", ResultType: "success"}, nil + }, + } + guarded := WrapSovrintTool( + tool, + SovrintToolGuardOptions{ + Profile: SovrintReadOnlyProfile, + AuditSink: auditOK, + Authorize: func(invocation ToolInvocation) (bool, error) { return false, nil }, + }, + ) + result, err := guarded.Handler(ToolInvocation{ + SessionID: "session-4", + ToolCallID: "call-1", + ToolName: "inspect_state", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if called || result.ResultType != "rejected" { + t.Fatalf("expected guarded rejection: called=%v result=%#v", called, result) + } +} diff --git a/go/sovrint_tool_contract.go b/go/sovrint_tool_contract.go new file mode 100644 index 0000000000..0d8ee56857 --- /dev/null +++ b/go/sovrint_tool_contract.go @@ -0,0 +1,9 @@ +package copilot + +type SovrintToolAuthorizer func(ToolInvocation) (bool, error) + +type SovrintToolGuardOptions struct { + Profile SovrintSecurityProfile + AuditSink SovrintAuditSink + Authorize SovrintToolAuthorizer +} diff --git a/go/sovrint_tool_event.go b/go/sovrint_tool_event.go new file mode 100644 index 0000000000..d44e0b74a0 --- /dev/null +++ b/go/sovrint_tool_event.go @@ -0,0 +1,23 @@ +package copilot + +func recordSovrintToolEvent( + tool Tool, + invocation ToolInvocation, + parent SovrintAuditEvent, + options SovrintToolGuardOptions, + eventClass string, + decision string, + reasonCode string, +) { + event := newSovrintAuditEvent( + options.Profile, + eventClass, + invocation.SessionID, + decision, + reasonCode, + ) + event.ParentEventID = parent.EventID + event.ToolCallID = invocation.ToolCallID + event.ToolName = tool.Name + emitSovrintAudit(options.Profile, options.AuditSink, event) +} diff --git a/go/sovrint_tool_execute.go b/go/sovrint_tool_execute.go new file mode 100644 index 0000000000..dfa6e91bb3 --- /dev/null +++ b/go/sovrint_tool_execute.go @@ -0,0 +1,32 @@ +package copilot + +func executeSovrintTool( + tool Tool, + handler ToolHandler, + invocation ToolInvocation, + started SovrintAuditEvent, + options SovrintToolGuardOptions, +) ToolResult { + if handler == nil { + return finishSovrintTool( + tool, + invocation, + started, + options, + "CUSTOM_TOOL_HANDLER_MISSING", + nil, + ) + } + result, err := handler(invocation) + if err != nil { + return finishSovrintTool( + tool, + invocation, + started, + options, + err.Error(), + nil, + ) + } + return finishSovrintTool(tool, invocation, started, options, "", &result) +} diff --git a/go/sovrint_tool_finish.go b/go/sovrint_tool_finish.go new file mode 100644 index 0000000000..5969d81293 --- /dev/null +++ b/go/sovrint_tool_finish.go @@ -0,0 +1,33 @@ +package copilot + +func finishSovrintTool( + tool Tool, + invocation ToolInvocation, + started SovrintAuditEvent, + options SovrintToolGuardOptions, + reason string, + result *ToolResult, +) ToolResult { + if result == nil { + recordSovrintToolEvent( + tool, + invocation, + started, + options, + "TOOL_INVOCATION_FAILED", + "FAILED", + "CUSTOM_TOOL_FAILED", + ) + return sovrintToolOutcome(reason, "failure") + } + recordSovrintToolEvent( + tool, + invocation, + started, + options, + "TOOL_INVOCATION_COMPLETED", + "RECORDED", + "CUSTOM_TOOL_COMPLETED", + ) + return *result +} diff --git a/go/sovrint_tool_outcome.go b/go/sovrint_tool_outcome.go new file mode 100644 index 0000000000..b8ed4619da --- /dev/null +++ b/go/sovrint_tool_outcome.go @@ -0,0 +1,13 @@ +package copilot + +func sovrintToolOutcome(reasonCode string, resultType string) ToolResult { + return ToolResult{ + TextResultForLLM: "The governed tool invocation did not proceed.", + ResultType: resultType, + Error: reasonCode, + ToolTelemetry: map[string]interface{}{ + "source": "sovrint", + "reasonCode": reasonCode, + }, + } +} diff --git a/go/sovrint_tool_wrap.go b/go/sovrint_tool_wrap.go new file mode 100644 index 0000000000..2f6869406f --- /dev/null +++ b/go/sovrint_tool_wrap.go @@ -0,0 +1,48 @@ +package copilot + +// WrapSovrintTool adds application authorization and bounded audit events. +func WrapSovrintTool(tool Tool, options SovrintToolGuardOptions) Tool { + original := tool.Handler + tool.Handler = func(invocation ToolInvocation) (ToolResult, error) { + started := newSovrintAuditEvent( + options.Profile, + "TOOL_INVOCATION_STARTED", + invocation.SessionID, + "PENDING", + "TOOL_INVOCATION_RECEIVED", + ) + started.ToolCallID = invocation.ToolCallID + started.ToolName = tool.Name + if !emitSovrintAudit(options.Profile, options.AuditSink, started) { + return sovrintToolOutcome("AUDIT_SINK_UNAVAILABLE", "rejected"), nil + } + + if options.Authorize != nil { + allowed, err := options.Authorize(invocation) + if err != nil || !allowed { + recordSovrintToolEvent( + tool, + invocation, + started, + options, + "TOOL_INVOCATION_REJECTED", + "REJECTED", + "CUSTOM_TOOL_AUTHORIZATION_DENIED", + ) + return sovrintToolOutcome("CUSTOM_TOOL_AUTHORIZATION_DENIED", "rejected"), nil + } + } + + recordSovrintToolEvent( + tool, + invocation, + started, + options, + "TOOL_INVOCATION_APPROVED", + "APPROVED", + "CUSTOM_TOOL_AUTHORIZED", + ) + return executeSovrintTool(tool, original, invocation, started, options), nil + } + return tool +} diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index cfbd13b131..9f85e8e6cb 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -11,6 +11,28 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; export { defineTool } from "./types.js"; +export { + SOVRINT_READ_ONLY_PROFILE, + SOVRINT_RESEARCH_PROFILE, + SOVRINT_STRICT_PROFILE, + SOVRINT_SYSTEM_APPEND, + applySovrintProfile, + createSovrintPermissionHandler, + wrapSovrintTool, +} from "./sovrint.js"; +export type { + SovrintApplyProfileOptions, + SovrintAuditDecision, + SovrintAuditEvent, + SovrintAuditEventClass, + SovrintAuditSink, + SovrintPermissionDecision, + SovrintPermissionEvaluator, + SovrintPermissionHandlerOptions, + SovrintSecurityProfile, + SovrintToolGuardOptions, + SovrintToolSurfaceMode, +} from "./sovrint.js"; export type { ConnectionState, CopilotClientOptions, diff --git a/nodejs/src/sovrint.ts b/nodejs/src/sovrint.ts new file mode 100644 index 0000000000..f54cb80184 --- /dev/null +++ b/nodejs/src/sovrint.ts @@ -0,0 +1,529 @@ +import type { + PermissionHandler, + PermissionRequest, + PermissionRequestResult, + SessionConfig, + Tool, + ToolInvocation, + ToolResultObject, +} from "./types.js"; + +export type SovrintPermissionDecision = "approve" | "deny"; +export type SovrintToolSurfaceMode = "inherit" | "allowlist" | "none"; +export type SovrintAuditDecision = + | "APPROVED" + | "DENIED" + | "REJECTED" + | "FAILED" + | "RECORDED" + | "PENDING"; + +export type SovrintAuditEventClass = + | "PROFILE_APPLIED" + | "PERMISSION_DECISION" + | "TOOL_INVOCATION_STARTED" + | "TOOL_INVOCATION_APPROVED" + | "TOOL_INVOCATION_REJECTED" + | "TOOL_INVOCATION_COMPLETED" + | "TOOL_INVOCATION_FAILED" + | "SYSTEM_MESSAGE_REPLACE_REJECTED" + | "POLICY_VIOLATION" + | "AUDIT_SINK_FAILURE"; + +export interface SovrintSecurityProfile { + profileId: string; + version: string; + description?: string; + defaultDecision: SovrintPermissionDecision; + allowKinds?: PermissionRequest["kind"][]; + denyKinds?: PermissionRequest["kind"][]; + toolSurfaceMode?: SovrintToolSurfaceMode; + availableTools?: string[]; + excludedTools?: string[]; + allowedMcpServers?: string[]; + allowedSkillDirectories?: string[]; + disabledSkills?: string[]; + forbidSystemMessageReplace?: boolean; + failClosedOnAuditError?: boolean; + auditEnabled?: boolean; + systemMessageAppend?: string; +} + +export interface SovrintAuditEvent { + schemaVersion: "1.0"; + eventId: string; + parentEventId?: string | null; + eventClass: SovrintAuditEventClass; + timestampUtc: string; + profileId: string; + profileVersion: string; + sessionId: string; + toolCallId?: string | null; + toolName?: string | null; + permissionKind?: PermissionRequest["kind"] | "unknown" | null; + decision: SovrintAuditDecision; + reasonCode: string; + targetClass?: string | null; + disclosureClass: "PUBLIC" | "INTERNAL" | "PROTECTED" | "RESTRICTED"; + evidenceStatus: + | "NOT_SUBMITTED" + | "SUBMISSION_PENDING" + | "SUBMITTED" + | "ACCEPTED" + | "REJECTED" + | "QUARANTINED" + | "RETRYABLE"; + metadata?: Record; +} + +export type SovrintAuditSink = (event: SovrintAuditEvent) => Promise | void; + +export type SovrintPermissionEvaluator = ( + request: PermissionRequest, + invocation: { sessionId: string } +) => Promise | SovrintPermissionDecision | undefined; + +export interface SovrintPermissionHandlerOptions { + auditSink?: SovrintAuditSink; + evaluate?: SovrintPermissionEvaluator; + downstream?: PermissionHandler; +} + +export interface SovrintApplyProfileOptions { + auditSink?: SovrintAuditSink; + evaluatePermission?: SovrintPermissionEvaluator; +} + +export interface SovrintToolGuardOptions { + profile: SovrintSecurityProfile; + auditSink?: SovrintAuditSink; + authorize?: ( + args: TArgs, + invocation: ToolInvocation + ) => Promise | boolean; + failureMode?: "result" | "rethrow"; +} + +export const SOVRINT_SYSTEM_APPEND = [ + "Operate under a bounded SOVRINT governed-session profile.", + "Use only explicitly exposed tools and declared authority.", + "Treat observations, inferences, recommendations, governance decisions, integrity findings, and accepted evidence as distinct classes.", + "Do not claim approval, verification, restoration, or EvidenceGrid acceptance without an explicit external result.", +].join(" "); + +export const SOVRINT_STRICT_PROFILE: Readonly = { + profileId: "sovrint.strict", + version: "1.0", + description: "Deny every permission and expose no inherited first-party tools.", + defaultDecision: "deny", + allowKinds: [], + denyKinds: ["read", "write", "shell", "url", "mcp"], + toolSurfaceMode: "none", + availableTools: [], + excludedTools: [], + allowedMcpServers: [], + allowedSkillDirectories: [], + disabledSkills: [], + forbidSystemMessageReplace: true, + failClosedOnAuditError: true, + auditEnabled: true, + systemMessageAppend: SOVRINT_SYSTEM_APPEND, +}; + +export const SOVRINT_READ_ONLY_PROFILE: Readonly = { + profileId: "sovrint.read-only", + version: "1.0", + description: "Permit read requests while denying mutating and external permission kinds.", + defaultDecision: "deny", + allowKinds: ["read"], + denyKinds: ["write", "shell", "url", "mcp"], + toolSurfaceMode: "inherit", + excludedTools: [], + allowedMcpServers: [], + allowedSkillDirectories: [], + disabledSkills: [], + forbidSystemMessageReplace: true, + failClosedOnAuditError: true, + auditEnabled: true, + systemMessageAppend: `${SOVRINT_SYSTEM_APPEND} Operate in read-only mode.`, +}; + +export const SOVRINT_RESEARCH_PROFILE: Readonly = { + profileId: "sovrint.research", + version: "1.0", + description: "Permit reads and require an application evaluator for every other permission kind.", + defaultDecision: "deny", + allowKinds: ["read"], + denyKinds: [], + toolSurfaceMode: "inherit", + excludedTools: [], + allowedMcpServers: [], + allowedSkillDirectories: [], + disabledSkills: [], + forbidSystemMessageReplace: true, + failClosedOnAuditError: false, + auditEnabled: true, + systemMessageAppend: `${SOVRINT_SYSTEM_APPEND} Separate research observations from verified findings.`, +}; + +let auditSequence = 0; + +function createAuditEvent( + profile: SovrintSecurityProfile, + event: Omit< + SovrintAuditEvent, + "schemaVersion" | "eventId" | "timestampUtc" | "profileId" | "profileVersion" + > +): SovrintAuditEvent { + auditSequence += 1; + return { + schemaVersion: "1.0", + eventId: `sovrint-${Date.now()}-${auditSequence}`, + timestampUtc: new Date().toISOString(), + profileId: profile.profileId, + profileVersion: profile.version, + ...event, + }; +} + +async function emitAuditEvent( + profile: SovrintSecurityProfile, + sink: SovrintAuditSink | undefined, + event: SovrintAuditEvent +): Promise { + if (!profile.auditEnabled) { + return true; + } + if (!sink) { + return !profile.failClosedOnAuditError; + } + try { + await sink(event); + return true; + } catch { + return !profile.failClosedOnAuditError; + } +} + +function permissionResult( + approved: boolean, + profile: SovrintSecurityProfile, + reasonCode: string +): PermissionRequestResult { + return { + kind: approved ? "approved" : "denied-by-rules", + rules: [ + { + source: "sovrint", + profileId: profile.profileId, + profileVersion: profile.version, + reasonCode, + }, + ], + }; +} + +async function evaluateProfilePermission( + profile: SovrintSecurityProfile, + request: PermissionRequest, + invocation: { sessionId: string }, + evaluator?: SovrintPermissionEvaluator +): Promise<{ approved: boolean; reasonCode: string }> { + if (!request.kind) { + return { approved: false, reasonCode: "UNKNOWN_PERMISSION_KIND" }; + } + + if (profile.denyKinds?.includes(request.kind)) { + return { approved: false, reasonCode: "PROFILE_EXPLICIT_DENY" }; + } + + if (evaluator) { + try { + const result = await evaluator(request, invocation); + if (result === "approve") { + return { approved: true, reasonCode: "APPLICATION_EVALUATOR_APPROVED" }; + } + if (result === "deny") { + return { approved: false, reasonCode: "APPLICATION_EVALUATOR_DENIED" }; + } + } catch { + return { approved: false, reasonCode: "APPLICATION_EVALUATOR_FAILED" }; + } + } + + if (profile.allowKinds?.includes(request.kind)) { + return { approved: true, reasonCode: "PROFILE_EXPLICIT_ALLOW" }; + } + + return profile.defaultDecision === "approve" + ? { approved: true, reasonCode: "PROFILE_DEFAULT_ALLOW" } + : { approved: false, reasonCode: "PROFILE_DEFAULT_DENY" }; +} + +export function createSovrintPermissionHandler( + profile: SovrintSecurityProfile, + options: SovrintPermissionHandlerOptions = {} +): PermissionHandler { + return async (request, invocation) => { + const profileDecision = await evaluateProfilePermission( + profile, + request, + invocation, + options.evaluate + ); + + let approved = profileDecision.approved; + let reasonCode = profileDecision.reasonCode; + + if (approved && options.downstream) { + try { + const downstreamResult = await options.downstream(request, invocation); + if (downstreamResult.kind !== "approved") { + approved = false; + reasonCode = "DOWNSTREAM_HANDLER_DENIED"; + } + } catch { + approved = false; + reasonCode = "DOWNSTREAM_HANDLER_FAILED"; + } + } + + const event = createAuditEvent(profile, { + eventClass: "PERMISSION_DECISION", + sessionId: invocation.sessionId, + toolCallId: request.toolCallId ?? null, + toolName: null, + permissionKind: request.kind ?? "unknown", + decision: approved ? "APPROVED" : "DENIED", + reasonCode, + disclosureClass: "INTERNAL", + evidenceStatus: "NOT_SUBMITTED", + }); + + const auditRecorded = await emitAuditEvent(profile, options.auditSink, event); + if (!auditRecorded && approved) { + return permissionResult(false, profile, "AUDIT_SINK_UNAVAILABLE"); + } + + return permissionResult(approved, profile, reasonCode); + }; +} + +function intersectTools(left: string[], right: string[]): string[] { + const allowed = new Set(right); + return left.filter((tool) => allowed.has(tool)); +} + +function mergeUnique(left: string[] = [], right: string[] = []): string[] { + return [...new Set([...left, ...right])]; +} + +function applySystemMessage( + config: SessionConfig, + profile: SovrintSecurityProfile +): SessionConfig["systemMessage"] { + if (config.systemMessage?.mode === "replace") { + if (profile.forbidSystemMessageReplace !== false) { + throw new Error( + `SOVRINT profile '${profile.profileId}' forbids system-message replacement` + ); + } + return config.systemMessage; + } + + const existing = config.systemMessage?.content?.trim(); + const appended = profile.systemMessageAppend?.trim(); + const content = [existing, appended].filter(Boolean).join("\n\n"); + return content ? { mode: "append", content } : config.systemMessage; +} + +export function applySovrintProfile( + config: SessionConfig, + profile: SovrintSecurityProfile, + options: SovrintApplyProfileOptions = {} +): SessionConfig { + let availableTools = config.availableTools; + + if (profile.toolSurfaceMode === "none") { + availableTools = []; + } else if (profile.toolSurfaceMode === "allowlist") { + const profileTools = profile.availableTools ?? []; + availableTools = config.availableTools + ? intersectTools(config.availableTools, profileTools) + : [...profileTools]; + } + + let customAgents = config.customAgents; + if (customAgents && profile.toolSurfaceMode !== "inherit") { + customAgents = customAgents.map((agent) => { + if (profile.toolSurfaceMode === "none") { + return { ...agent, tools: [] }; + } + const profileTools = profile.availableTools ?? []; + const agentTools = agent.tools ?? profileTools; + return { ...agent, tools: intersectTools(agentTools, profileTools) }; + }); + } + + let mcpServers = config.mcpServers; + if (mcpServers && profile.allowedMcpServers) { + const allowed = new Set(profile.allowedMcpServers); + mcpServers = Object.fromEntries( + Object.entries(mcpServers).filter(([name]) => allowed.has(name)) + ); + } + + let skillDirectories = config.skillDirectories; + if (skillDirectories && profile.allowedSkillDirectories) { + const allowed = new Set(profile.allowedSkillDirectories); + skillDirectories = skillDirectories.filter((directory) => allowed.has(directory)); + } + + return { + ...config, + availableTools, + excludedTools: mergeUnique(config.excludedTools, profile.excludedTools), + systemMessage: applySystemMessage(config, profile), + onPermissionRequest: createSovrintPermissionHandler(profile, { + auditSink: options.auditSink, + evaluate: options.evaluatePermission, + downstream: config.onPermissionRequest, + }), + customAgents, + mcpServers, + skillDirectories, + disabledSkills: mergeUnique(config.disabledSkills, profile.disabledSkills), + }; +} + +function rejectedToolResult(reasonCode: string): ToolResultObject { + return { + textResultForLlm: "The governed tool invocation was not authorized.", + resultType: "rejected", + error: reasonCode, + toolTelemetry: { source: "sovrint", reasonCode }, + }; +} + +export function wrapSovrintTool( + tool: Tool, + options: SovrintToolGuardOptions +): Tool { + const { profile } = options; + + return { + ...tool, + handler: async (args: TArgs, invocation: ToolInvocation) => { + const started = createAuditEvent(profile, { + eventClass: "TOOL_INVOCATION_STARTED", + sessionId: invocation.sessionId, + toolCallId: invocation.toolCallId, + toolName: tool.name, + permissionKind: null, + decision: "PENDING", + reasonCode: "TOOL_INVOCATION_RECEIVED", + disclosureClass: "INTERNAL", + evidenceStatus: "NOT_SUBMITTED", + }); + + const startRecorded = await emitAuditEvent(profile, options.auditSink, started); + if (!startRecorded) { + return rejectedToolResult("AUDIT_SINK_UNAVAILABLE"); + } + + if (options.authorize) { + let authorized = false; + try { + authorized = await options.authorize(args, invocation); + } catch { + authorized = false; + } + + if (!authorized) { + await emitAuditEvent( + profile, + options.auditSink, + createAuditEvent(profile, { + parentEventId: started.eventId, + eventClass: "TOOL_INVOCATION_REJECTED", + sessionId: invocation.sessionId, + toolCallId: invocation.toolCallId, + toolName: tool.name, + permissionKind: null, + decision: "REJECTED", + reasonCode: "CUSTOM_TOOL_AUTHORIZATION_DENIED", + disclosureClass: "INTERNAL", + evidenceStatus: "NOT_SUBMITTED", + }) + ); + return rejectedToolResult("CUSTOM_TOOL_AUTHORIZATION_DENIED"); + } + } + + await emitAuditEvent( + profile, + options.auditSink, + createAuditEvent(profile, { + parentEventId: started.eventId, + eventClass: "TOOL_INVOCATION_APPROVED", + sessionId: invocation.sessionId, + toolCallId: invocation.toolCallId, + toolName: tool.name, + permissionKind: null, + decision: "APPROVED", + reasonCode: "CUSTOM_TOOL_AUTHORIZED", + disclosureClass: "INTERNAL", + evidenceStatus: "NOT_SUBMITTED", + }) + ); + + try { + const result = await tool.handler(args, invocation); + await emitAuditEvent( + profile, + options.auditSink, + createAuditEvent(profile, { + parentEventId: started.eventId, + eventClass: "TOOL_INVOCATION_COMPLETED", + sessionId: invocation.sessionId, + toolCallId: invocation.toolCallId, + toolName: tool.name, + permissionKind: null, + decision: "RECORDED", + reasonCode: "CUSTOM_TOOL_COMPLETED", + disclosureClass: "INTERNAL", + evidenceStatus: "NOT_SUBMITTED", + }) + ); + return result; + } catch (error) { + await emitAuditEvent( + profile, + options.auditSink, + createAuditEvent(profile, { + parentEventId: started.eventId, + eventClass: "TOOL_INVOCATION_FAILED", + sessionId: invocation.sessionId, + toolCallId: invocation.toolCallId, + toolName: tool.name, + permissionKind: null, + decision: "FAILED", + reasonCode: "CUSTOM_TOOL_FAILED", + disclosureClass: "INTERNAL", + evidenceStatus: "NOT_SUBMITTED", + }) + ); + + if (options.failureMode === "result") { + return { + textResultForLlm: "The governed tool invocation failed.", + resultType: "failure", + error: error instanceof Error ? error.message : "unknown error", + toolTelemetry: { source: "sovrint" }, + } satisfies ToolResultObject; + } + throw error; + } + }, + }; +} diff --git a/nodejs/test/sovrint.test.ts b/nodejs/test/sovrint.test.ts new file mode 100644 index 0000000000..40345553ea --- /dev/null +++ b/nodejs/test/sovrint.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import { + SOVRINT_READ_ONLY_PROFILE, + SOVRINT_RESEARCH_PROFILE, + SOVRINT_STRICT_PROFILE, + applySovrintProfile, + createSovrintPermissionHandler, + wrapSovrintTool, + type SovrintAuditEvent, + type Tool, +} from "../src/index.js"; + +describe("SOVRINT governed SDK profile", () => { + it("preserves strict explicit denial even when an evaluator approves", async () => { + const events: SovrintAuditEvent[] = []; + const handler = createSovrintPermissionHandler(SOVRINT_STRICT_PROFILE, { + auditSink: (event) => events.push(event), + evaluate: () => "approve", + }); + + const result = await handler( + { kind: "read", toolCallId: "call-1" }, + { sessionId: "session-1" } + ); + + expect(result.kind).toBe("denied-by-rules"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventClass: "PERMISSION_DECISION", + decision: "DENIED", + reasonCode: "PROFILE_EXPLICIT_DENY", + }); + }); + + it("approves reads and denies writes under the read-only profile", async () => { + const events: SovrintAuditEvent[] = []; + const handler = createSovrintPermissionHandler(SOVRINT_READ_ONLY_PROFILE, { + auditSink: (event) => events.push(event), + }); + + const read = await handler({ kind: "read" }, { sessionId: "session-2" }); + const write = await handler({ kind: "write" }, { sessionId: "session-2" }); + + expect(read.kind).toBe("approved"); + expect(write.kind).toBe("denied-by-rules"); + expect(events.map((event) => event.decision)).toEqual(["APPROVED", "DENIED"]); + }); + + it("allows the research evaluator to approve a request not explicitly allowed", async () => { + const handler = createSovrintPermissionHandler(SOVRINT_RESEARCH_PROFILE, { + auditSink: () => undefined, + evaluate: (request) => (request.kind === "url" ? "approve" : undefined), + }); + + const result = await handler({ kind: "url" }, { sessionId: "session-3" }); + expect(result.kind).toBe("approved"); + }); + + it("preserves a downstream permission denial", async () => { + const handler = createSovrintPermissionHandler(SOVRINT_READ_ONLY_PROFILE, { + auditSink: () => undefined, + downstream: () => ({ kind: "denied-interactively-by-user" }), + }); + + const result = await handler({ kind: "read" }, { sessionId: "session-4" }); + expect(result.kind).toBe("denied-by-rules"); + expect(result.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reasonCode: "DOWNSTREAM_HANDLER_DENIED" }), + ]) + ); + }); + + it("fails closed when an audit sink fails", async () => { + const handler = createSovrintPermissionHandler(SOVRINT_READ_ONLY_PROFILE, { + auditSink: () => { + throw new Error("sink unavailable"); + }, + }); + + const result = await handler({ kind: "read" }, { sessionId: "session-5" }); + expect(result.kind).toBe("denied-by-rules"); + expect(result.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reasonCode: "AUDIT_SINK_UNAVAILABLE" }), + ]) + ); + }); + + it("rejects system-message replacement by default", () => { + expect(() => + applySovrintProfile( + { + systemMessage: { mode: "replace", content: "replacement" }, + }, + SOVRINT_READ_ONLY_PROFILE, + { auditSink: () => undefined } + ) + ).toThrow(/forbids system-message replacement/); + }); + + it("removes inherited tools, MCP servers, skills, and custom-agent tools in strict mode", () => { + const config = applySovrintProfile( + { + availableTools: ["read_file", "write_file"], + mcpServers: { + alpha: { type: "http", url: "https://example.invalid", tools: ["*"] }, + }, + skillDirectories: ["./skills"], + customAgents: [ + { + name: "worker", + prompt: "work", + tools: null, + }, + ], + }, + SOVRINT_STRICT_PROFILE, + { auditSink: () => undefined } + ); + + expect(config.availableTools).toEqual([]); + expect(config.mcpServers).toEqual({}); + expect(config.skillDirectories).toEqual([]); + expect(config.customAgents?.[0].tools).toEqual([]); + expect(config.systemMessage).toMatchObject({ mode: "append" }); + }); + + it("rejects an unauthorized custom tool without calling its handler", async () => { + const originalHandler = vi.fn(() => "ok"); + const tool: Tool<{ value: string }> = { + name: "mutate_state", + description: "Test tool", + parameters: { type: "object" }, + handler: originalHandler, + }; + const guarded = wrapSovrintTool(tool, { + profile: SOVRINT_READ_ONLY_PROFILE, + auditSink: () => undefined, + authorize: () => false, + }); + + const result = await guarded.handler( + { value: "x" }, + { + sessionId: "session-6", + toolCallId: "tool-call-1", + toolName: "mutate_state", + arguments: { value: "x" }, + } + ); + + expect(originalHandler).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + resultType: "rejected", + error: "CUSTOM_TOOL_AUTHORIZATION_DENIED", + }); + }); + + it("records a completed authorized custom tool invocation", async () => { + const events: SovrintAuditEvent[] = []; + const tool: Tool<{ value: string }> = { + name: "inspect_state", + description: "Test tool", + parameters: { type: "object" }, + handler: ({ value }) => ({ value }), + }; + const guarded = wrapSovrintTool(tool, { + profile: SOVRINT_READ_ONLY_PROFILE, + auditSink: (event) => events.push(event), + authorize: () => true, + }); + + const result = await guarded.handler( + { value: "ok" }, + { + sessionId: "session-7", + toolCallId: "tool-call-2", + toolName: "inspect_state", + arguments: { value: "ok" }, + } + ); + + expect(result).toEqual({ value: "ok" }); + expect(events.map((event) => event.eventClass)).toEqual([ + "TOOL_INVOCATION_STARTED", + "TOOL_INVOCATION_APPROVED", + "TOOL_INVOCATION_COMPLETED", + ]); + expect(events.every((event) => event.metadata === undefined)).toBe(true); + }); +}); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 47a4ab6d93..2ba896de6e 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -6,6 +6,16 @@ from .client import CopilotClient from .session import CopilotSession +from .sovrint_runtime import ( + SOVRINT_READ_ONLY_PROFILE, + SOVRINT_RESEARCH_PROFILE, + SOVRINT_STRICT_PROFILE, + SOVRINT_SYSTEM_APPEND, + SovrintSecurityProfile, + apply_sovrint_profile, + create_sovrint_permission_handler, + wrap_sovrint_tool, +) from .tools import define_tool from .types import ( AzureProviderOptions, @@ -57,11 +67,19 @@ "PermissionRequestResult", "ProviderConfig", "ResumeSessionConfig", + "SOVRINT_READ_ONLY_PROFILE", + "SOVRINT_RESEARCH_PROFILE", + "SOVRINT_STRICT_PROFILE", + "SOVRINT_SYSTEM_APPEND", "SessionConfig", "SessionEvent", + "SovrintSecurityProfile", "Tool", "ToolHandler", "ToolInvocation", "ToolResult", + "apply_sovrint_profile", + "create_sovrint_permission_handler", "define_tool", + "wrap_sovrint_tool", ] diff --git a/python/copilot/sovrint.py b/python/copilot/sovrint.py new file mode 100644 index 0000000000..424076d1eb --- /dev/null +++ b/python/copilot/sovrint.py @@ -0,0 +1,12 @@ +"""Compatibility imports for the modular SOVRINT runtime.""" + +from .sovrint_runtime import SOVRINT_READ_ONLY_PROFILE as SOVRINT_READ_ONLY_PROFILE +from .sovrint_runtime import SOVRINT_RESEARCH_PROFILE as SOVRINT_RESEARCH_PROFILE +from .sovrint_runtime import SOVRINT_STRICT_PROFILE as SOVRINT_STRICT_PROFILE +from .sovrint_runtime import SOVRINT_SYSTEM_APPEND as SOVRINT_SYSTEM_APPEND +from .sovrint_runtime import SovrintSecurityProfile as SovrintSecurityProfile +from .sovrint_runtime import apply_sovrint_profile as apply_sovrint_profile +from .sovrint_runtime import ( + create_sovrint_permission_handler as create_sovrint_permission_handler, +) +from .sovrint_runtime import wrap_sovrint_tool as wrap_sovrint_tool diff --git a/python/copilot/sovrint_runtime/__init__.py b/python/copilot/sovrint_runtime/__init__.py new file mode 100644 index 0000000000..5d9441a515 --- /dev/null +++ b/python/copilot/sovrint_runtime/__init__.py @@ -0,0 +1,12 @@ +"""Public SOVRINT governed-session runtime API.""" + +from .model import SOVRINT_READ_ONLY_PROFILE as SOVRINT_READ_ONLY_PROFILE +from .model import SOVRINT_RESEARCH_PROFILE as SOVRINT_RESEARCH_PROFILE +from .model import SOVRINT_STRICT_PROFILE as SOVRINT_STRICT_PROFILE +from .model import SOVRINT_SYSTEM_APPEND as SOVRINT_SYSTEM_APPEND +from .model import SovrintSecurityProfile as SovrintSecurityProfile +from .permissions import ( + create_sovrint_permission_handler as create_sovrint_permission_handler, +) +from .projection import apply_sovrint_profile as apply_sovrint_profile +from .tools import wrap_sovrint_tool as wrap_sovrint_tool diff --git a/python/copilot/sovrint_runtime/audit.py b/python/copilot/sovrint_runtime/audit.py new file mode 100644 index 0000000000..b76a657215 --- /dev/null +++ b/python/copilot/sovrint_runtime/audit.py @@ -0,0 +1,72 @@ +"""Bounded SOVRINT audit events for governed SDK sessions.""" + +from __future__ import annotations + +import inspect +import itertools +from datetime import datetime, timezone +from typing import Any + +from .model import AuditEvent, AuditSink, SovrintSecurityProfile + +_sequence = itertools.count(1) + + +async def resolve(value: Any) -> Any: + """Resolve synchronous and awaitable application callbacks.""" + + if inspect.isawaitable(value): + return await value + return value + + +def create_audit_event( + profile: SovrintSecurityProfile, + event_class: str, + session_id: str, + decision: str, + reason_code: str, + *, + parent_event_id: str | None = None, + tool_call_id: str | None = None, + tool_name: str | None = None, + permission_kind: str | None = None, +) -> AuditEvent: + """Create a bounded event without prompts, arguments, results, or credentials.""" + + now = datetime.now(timezone.utc) + return { + "schemaVersion": "1.0", + "eventId": f"sovrint-{int(now.timestamp() * 1000)}-{next(_sequence)}", + "parentEventId": parent_event_id, + "eventClass": event_class, + "timestampUtc": now.isoformat().replace("+00:00", "Z"), + "profileId": profile.profile_id, + "profileVersion": profile.version, + "sessionId": session_id, + "toolCallId": tool_call_id, + "toolName": tool_name, + "permissionKind": permission_kind, + "decision": decision, + "reasonCode": reason_code, + "disclosureClass": "INTERNAL", + "evidenceStatus": "NOT_SUBMITTED", + } + + +async def emit_audit_event( + profile: SovrintSecurityProfile, + sink: AuditSink | None, + event: AuditEvent, +) -> bool: + """Record an event and return whether execution may continue.""" + + if not profile.audit_enabled: + return True + if sink is None: + return not profile.fail_closed_on_audit_error + try: + await resolve(sink(event)) + except Exception: + return not profile.fail_closed_on_audit_error + return True diff --git a/python/copilot/sovrint_runtime/model.py b/python/copilot/sovrint_runtime/model.py new file mode 100644 index 0000000000..65c53abfb3 --- /dev/null +++ b/python/copilot/sovrint_runtime/model.py @@ -0,0 +1,78 @@ +"""SOVRINT governed-session models and canonical built-in profiles.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union + +from ..types import PermissionRequest, ToolInvocation + +PermissionDecision = str +AuditEvent = Dict[str, Any] +AuditSink = Callable[[AuditEvent], Union[None, Awaitable[None]]] +PermissionEvaluator = Callable[ + [PermissionRequest, Dict[str, str]], + Union[Optional[PermissionDecision], Awaitable[Optional[PermissionDecision]]], +] +ToolAuthorizer = Callable[[ToolInvocation], Union[bool, Awaitable[bool]]] + +SOVRINT_SYSTEM_APPEND = ( + "Operate under a bounded SOVRINT governed-session profile. " + "Use only explicitly exposed tools and declared authority. " + "Keep observations, inferences, recommendations, governance decisions, " + "integrity findings, and accepted evidence distinct. " + "Do not claim approval, verification, restoration, or EvidenceGrid acceptance " + "without an explicit external result." +) + + +@dataclass(frozen=True) +class SovrintSecurityProfile: + """Versioned governed-session policy.""" + + profile_id: str + version: str + default_decision: PermissionDecision = "deny" + allow_kinds: Tuple[str, ...] = () + deny_kinds: Tuple[str, ...] = () + tool_surface_mode: str = "inherit" + available_tools: Tuple[str, ...] = () + excluded_tools: Tuple[str, ...] = () + allowed_mcp_servers: Tuple[str, ...] = () + allowed_skill_directories: Tuple[str, ...] = () + disabled_skills: Tuple[str, ...] = () + forbid_system_message_replace: bool = True + fail_closed_on_audit_error: bool = False + audit_enabled: bool = True + system_message_append: str = SOVRINT_SYSTEM_APPEND + description: str = "" + + +SOVRINT_STRICT_PROFILE = SovrintSecurityProfile( + profile_id="sovrint.strict", + version="1.0", + description="Deny every permission and expose no inherited first-party tools.", + deny_kinds=("read", "write", "shell", "url", "mcp"), + tool_surface_mode="none", + fail_closed_on_audit_error=True, +) + +SOVRINT_READ_ONLY_PROFILE = SovrintSecurityProfile( + profile_id="sovrint.read-only", + version="1.0", + description="Permit reads while denying mutating and external permission kinds.", + allow_kinds=("read",), + deny_kinds=("write", "shell", "url", "mcp"), + fail_closed_on_audit_error=True, + system_message_append=f"{SOVRINT_SYSTEM_APPEND} Operate in read-only mode.", +) + +SOVRINT_RESEARCH_PROFILE = SovrintSecurityProfile( + profile_id="sovrint.research", + version="1.0", + description="Permit reads and defer other permission kinds to an application evaluator.", + allow_kinds=("read",), + system_message_append=( + f"{SOVRINT_SYSTEM_APPEND} Separate research observations from verified findings." + ), +) diff --git a/python/copilot/sovrint_runtime/permissions.py b/python/copilot/sovrint_runtime/permissions.py new file mode 100644 index 0000000000..0abeef994c --- /dev/null +++ b/python/copilot/sovrint_runtime/permissions.py @@ -0,0 +1,98 @@ +"""Most-restrictive SOVRINT permission composition.""" + +from __future__ import annotations + +from typing import Dict, Optional, Tuple + +from ..types import PermissionHandler, PermissionRequest, PermissionRequestResult +from .audit import create_audit_event, emit_audit_event, resolve +from .model import AuditSink, PermissionEvaluator, SovrintSecurityProfile + + +def permission_result( + approved: bool, + profile: SovrintSecurityProfile, + reason_code: str, +) -> PermissionRequestResult: + """Create a native SDK permission result carrying SOVRINT lineage.""" + + return PermissionRequestResult( + kind="approved" if approved else "denied-by-rules", + rules=[ + { + "source": "sovrint", + "profileId": profile.profile_id, + "profileVersion": profile.version, + "reasonCode": reason_code, + } + ], + ) + + +async def evaluate_permission( + profile: SovrintSecurityProfile, + request: PermissionRequest, + invocation: Dict[str, str], + evaluator: Optional[PermissionEvaluator], +) -> Tuple[bool, str]: + """Evaluate explicit denial, application policy, allowance, then default.""" + + kind = request.get("kind") + if not kind: + return False, "UNKNOWN_PERMISSION_KIND" + if kind in profile.deny_kinds: + return False, "PROFILE_EXPLICIT_DENY" + if evaluator is not None: + try: + decision = await resolve(evaluator(request, invocation)) + except Exception: + return False, "APPLICATION_EVALUATOR_FAILED" + if decision == "approve": + return True, "APPLICATION_EVALUATOR_APPROVED" + if decision == "deny": + return False, "APPLICATION_EVALUATOR_DENIED" + if kind in profile.allow_kinds: + return True, "PROFILE_EXPLICIT_ALLOW" + if profile.default_decision == "approve": + return True, "PROFILE_DEFAULT_ALLOW" + return False, "PROFILE_DEFAULT_DENY" + + +def create_sovrint_permission_handler( + profile: SovrintSecurityProfile, + *, + audit_sink: Optional[AuditSink] = None, + evaluate: Optional[PermissionEvaluator] = None, + downstream: Optional[PermissionHandler] = None, +) -> PermissionHandler: + """Compose profile, application, and existing SDK decisions.""" + + async def handler( + request: PermissionRequest, + invocation: Dict[str, str], + ) -> PermissionRequestResult: + approved, reason = await evaluate_permission( + profile, request, invocation, evaluate + ) + if approved and downstream is not None: + try: + downstream_result = await resolve(downstream(request, invocation)) + if downstream_result.get("kind") != "approved": + approved, reason = False, "DOWNSTREAM_HANDLER_DENIED" + except Exception: + approved, reason = False, "DOWNSTREAM_HANDLER_FAILED" + + event = create_audit_event( + profile, + "PERMISSION_DECISION", + invocation.get("session_id", invocation.get("sessionId", "unknown")), + "APPROVED" if approved else "DENIED", + reason, + tool_call_id=request.get("toolCallId"), + permission_kind=request.get("kind", "unknown"), + ) + if not await emit_audit_event(profile, audit_sink, event) and approved: + return permission_result(False, profile, "AUDIT_SINK_UNAVAILABLE") + return permission_result(approved, profile, reason) + + return handler diff --git a/python/copilot/sovrint_runtime/projection.py b/python/copilot/sovrint_runtime/projection.py new file mode 100644 index 0000000000..baf0a509d3 --- /dev/null +++ b/python/copilot/sovrint_runtime/projection.py @@ -0,0 +1,3 @@ +"""Compatibility import for the SOVRINT session projection.""" + +from .projection_core import apply_sovrint_profile as apply_sovrint_profile diff --git a/python/copilot/sovrint_runtime/projection_core.py b/python/copilot/sovrint_runtime/projection_core.py new file mode 100644 index 0000000000..6ae91e7f30 --- /dev/null +++ b/python/copilot/sovrint_runtime/projection_core.py @@ -0,0 +1,39 @@ +"""Project SOVRINT policy onto an SDK session configuration.""" + +from typing import Optional + +from ..types import SessionConfig +from .model import AuditSink, PermissionEvaluator, SovrintSecurityProfile +from .permissions import create_sovrint_permission_handler +from .surface import constrain_surfaces + + +def apply_sovrint_profile( + config: SessionConfig, + profile: SovrintSecurityProfile, + *, + audit_sink: Optional[AuditSink] = None, + evaluate_permission: Optional[PermissionEvaluator] = None, +) -> SessionConfig: + """Return a profile-constrained copy of an SDK configuration.""" + + result: SessionConfig = dict(config) + current = config.get("system_message") + if current and current.get("mode") == "replace": + if profile.forbid_system_message_replace: + message = f"Profile '{profile.profile_id}' forbids the requested system mode" + raise ValueError(message) + else: + existing = (current or {}).get("content", "").strip() + appended = profile.system_message_append.strip() + content = "\n\n".join(value for value in (existing, appended) if value) + result["system_message"] = {"mode": "append", "content": content} + + constrain_surfaces(config, result, profile) + result["on_permission_request"] = create_sovrint_permission_handler( + profile, + audit_sink=audit_sink, + evaluate=evaluate_permission, + downstream=config.get("on_permission_request"), + ) + return result diff --git a/python/copilot/sovrint_runtime/surface.py b/python/copilot/sovrint_runtime/surface.py new file mode 100644 index 0000000000..412dbd92b1 --- /dev/null +++ b/python/copilot/sovrint_runtime/surface.py @@ -0,0 +1,69 @@ +"""Tool, agent, MCP, and skill surface constraints.""" + +from typing import List, Sequence + +from ..types import SessionConfig +from .model import SovrintSecurityProfile + + +def intersect(left: Sequence[str], right: Sequence[str]) -> List[str]: + allowed = set(right) + return [value for value in left if value in allowed] + + +def merge(left: Sequence[str], right: Sequence[str]) -> List[str]: + return list(dict.fromkeys([*left, *right])) + + +def constrain_surfaces( + source: SessionConfig, + result: SessionConfig, + profile: SovrintSecurityProfile, +) -> None: + """Project a profile onto SDK capability surfaces.""" + + if profile.tool_surface_mode == "none": + result["available_tools"] = [] + elif profile.tool_surface_mode == "allowlist": + configured = source.get("available_tools") + result["available_tools"] = ( + intersect(configured, profile.available_tools) + if configured is not None + else list(profile.available_tools) + ) + + result["excluded_tools"] = merge( + source.get("excluded_tools", []), profile.excluded_tools + ) + + agents = source.get("custom_agents") + if agents is not None and profile.tool_surface_mode != "inherit": + constrained = [] + for agent_source in agents: + agent = dict(agent_source) + agent["tools"] = ( + [] + if profile.tool_surface_mode == "none" + else intersect( + agent_source.get("tools") or profile.available_tools, + profile.available_tools, + ) + ) + constrained.append(agent) + result["custom_agents"] = constrained + + servers = source.get("mcp_servers") + if servers is not None: + allowed_servers = set(profile.allowed_mcp_servers) + result["mcp_servers"] = { + name: value for name, value in servers.items() if name in allowed_servers + } + + directories = source.get("skill_directories") + if directories is not None: + result["skill_directories"] = intersect( + directories, profile.allowed_skill_directories + ) + result["disabled_skills"] = merge( + source.get("disabled_skills", []), profile.disabled_skills + ) diff --git a/python/copilot/sovrint_runtime/tools.py b/python/copilot/sovrint_runtime/tools.py new file mode 100644 index 0000000000..7c59375db2 --- /dev/null +++ b/python/copilot/sovrint_runtime/tools.py @@ -0,0 +1,127 @@ +"""Authorization and audit guards for caller-defined SDK tools.""" + +from typing import Optional + +from ..types import Tool, ToolInvocation, ToolResult +from .audit import create_audit_event, emit_audit_event, resolve +from .model import AuditSink, SovrintSecurityProfile, ToolAuthorizer + + +def rejected_tool_result(reason: str) -> ToolResult: + """Create a bounded rejection result.""" + + return ToolResult( + textResultForLlm="The governed tool invocation was not authorized.", + resultType="rejected", + error=reason, + toolTelemetry={"source": "sovrint", "reasonCode": reason}, + ) + + +def wrap_sovrint_tool( + tool: Tool, + profile: SovrintSecurityProfile, + *, + audit_sink: Optional[AuditSink] = None, + authorize: Optional[ToolAuthorizer] = None, +) -> Tool: + """Wrap a caller-defined tool with authorization and bounded audit events.""" + + original = tool.handler + + async def guarded(invocation: ToolInvocation) -> ToolResult: + session_id = invocation.get("session_id", "unknown") + call_id = invocation.get("tool_call_id") + started = create_audit_event( + profile, + "TOOL_INVOCATION_STARTED", + session_id, + "PENDING", + "TOOL_INVOCATION_RECEIVED", + tool_call_id=call_id, + tool_name=tool.name, + ) + if not await emit_audit_event(profile, audit_sink, started): + return rejected_tool_result("AUDIT_SINK_UNAVAILABLE") + + try: + authorized = authorize is None or bool(await resolve(authorize(invocation))) + except Exception: + authorized = False + if not authorized: + await emit_audit_event( + profile, + audit_sink, + create_audit_event( + profile, + "TOOL_INVOCATION_REJECTED", + session_id, + "REJECTED", + "CUSTOM_TOOL_AUTHORIZATION_DENIED", + parent_event_id=started["eventId"], + tool_call_id=call_id, + tool_name=tool.name, + ), + ) + return rejected_tool_result("CUSTOM_TOOL_AUTHORIZATION_DENIED") + + await emit_audit_event( + profile, + audit_sink, + create_audit_event( + profile, + "TOOL_INVOCATION_APPROVED", + session_id, + "APPROVED", + "CUSTOM_TOOL_AUTHORIZED", + parent_event_id=started["eventId"], + tool_call_id=call_id, + tool_name=tool.name, + ), + ) + try: + result = await resolve(original(invocation)) + except Exception as exc: + await emit_audit_event( + profile, + audit_sink, + create_audit_event( + profile, + "TOOL_INVOCATION_FAILED", + session_id, + "FAILED", + "CUSTOM_TOOL_FAILED", + parent_event_id=started["eventId"], + tool_call_id=call_id, + tool_name=tool.name, + ), + ) + return ToolResult( + textResultForLlm="The governed tool invocation failed.", + resultType="failure", + error=str(exc), + toolTelemetry={"source": "sovrint"}, + ) + + await emit_audit_event( + profile, + audit_sink, + create_audit_event( + profile, + "TOOL_INVOCATION_COMPLETED", + session_id, + "RECORDED", + "CUSTOM_TOOL_COMPLETED", + parent_event_id=started["eventId"], + tool_call_id=call_id, + tool_name=tool.name, + ), + ) + return result + + return Tool( + name=tool.name, + description=tool.description, + parameters=tool.parameters, + handler=guarded, + ) diff --git a/python/test_sovrint.py b/python/test_sovrint.py new file mode 100644 index 0000000000..7344b69737 --- /dev/null +++ b/python/test_sovrint.py @@ -0,0 +1,209 @@ +import pytest + +from copilot import ( + SOVRINT_READ_ONLY_PROFILE, + SOVRINT_RESEARCH_PROFILE, + SOVRINT_STRICT_PROFILE, + Tool, + apply_sovrint_profile, + create_sovrint_permission_handler, + wrap_sovrint_tool, +) + + +@pytest.mark.asyncio +async def test_strict_profile_preserves_explicit_denial(): + events = [] + handler = create_sovrint_permission_handler( + SOVRINT_STRICT_PROFILE, + audit_sink=events.append, + evaluate=lambda request, invocation: "approve", + ) + + result = await handler( + {"kind": "read", "toolCallId": "call-1"}, + {"session_id": "session-1"}, + ) + + assert result["kind"] == "denied-by-rules" + assert events[0]["reasonCode"] == "PROFILE_EXPLICIT_DENY" + + +@pytest.mark.asyncio +async def test_read_only_profile_approves_read_and_denies_write(): + events = [] + handler = create_sovrint_permission_handler( + SOVRINT_READ_ONLY_PROFILE, + audit_sink=events.append, + ) + + read = await handler({"kind": "read"}, {"session_id": "session-2"}) + write = await handler({"kind": "write"}, {"session_id": "session-2"}) + + assert read["kind"] == "approved" + assert write["kind"] == "denied-by-rules" + assert [event["decision"] for event in events] == ["APPROVED", "DENIED"] + + +@pytest.mark.asyncio +async def test_research_evaluator_can_approve_non_default_request(): + handler = create_sovrint_permission_handler( + SOVRINT_RESEARCH_PROFILE, + audit_sink=lambda event: None, + evaluate=lambda request, invocation: ( + "approve" if request.get("kind") == "url" else None + ), + ) + + result = await handler({"kind": "url"}, {"session_id": "session-3"}) + assert result["kind"] == "approved" + + +@pytest.mark.asyncio +async def test_downstream_denial_is_preserved(): + async def downstream(request, invocation): + return {"kind": "denied-interactively-by-user"} + + handler = create_sovrint_permission_handler( + SOVRINT_READ_ONLY_PROFILE, + audit_sink=lambda event: None, + downstream=downstream, + ) + + result = await handler({"kind": "read"}, {"session_id": "session-4"}) + assert result["kind"] == "denied-by-rules" + assert result["rules"][0]["reasonCode"] == "DOWNSTREAM_HANDLER_DENIED" + + +@pytest.mark.asyncio +async def test_audit_failure_fails_closed(): + def failing_sink(event): + raise RuntimeError("sink unavailable") + + handler = create_sovrint_permission_handler( + SOVRINT_READ_ONLY_PROFILE, + audit_sink=failing_sink, + ) + + result = await handler({"kind": "read"}, {"session_id": "session-5"}) + assert result["kind"] == "denied-by-rules" + assert result["rules"][0]["reasonCode"] == "AUDIT_SINK_UNAVAILABLE" + + +def test_system_message_replacement_is_rejected(): + with pytest.raises(ValueError, match="forbids system-message replacement"): + apply_sovrint_profile( + {"system_message": {"mode": "replace", "content": "replacement"}}, + SOVRINT_READ_ONLY_PROFILE, + audit_sink=lambda event: None, + ) + + +def test_strict_profile_removes_inherited_surfaces(): + config = apply_sovrint_profile( + { + "available_tools": ["read_file", "write_file"], + "mcp_servers": { + "alpha": { + "type": "http", + "url": "https://example.invalid", + "tools": ["*"], + } + }, + "skill_directories": ["./skills"], + "custom_agents": [ + { + "name": "worker", + "prompt": "work", + "tools": None, + } + ], + }, + SOVRINT_STRICT_PROFILE, + audit_sink=lambda event: None, + ) + + assert config["available_tools"] == [] + assert config["mcp_servers"] == {} + assert config["skill_directories"] == [] + assert config["custom_agents"][0]["tools"] == [] + assert config["system_message"]["mode"] == "append" + + +@pytest.mark.asyncio +async def test_guarded_tool_rejects_without_calling_original_handler(): + calls = [] + + async def original(invocation): + calls.append(invocation) + return { + "textResultForLlm": "ok", + "resultType": "success", + } + + tool = Tool( + name="mutate_state", + description="Test tool", + parameters={"type": "object"}, + handler=original, + ) + guarded = wrap_sovrint_tool( + tool, + SOVRINT_READ_ONLY_PROFILE, + audit_sink=lambda event: None, + authorize=lambda invocation: False, + ) + + result = await guarded.handler( + { + "session_id": "session-6", + "tool_call_id": "tool-call-1", + "tool_name": "mutate_state", + "arguments": {"value": "x"}, + } + ) + + assert calls == [] + assert result["resultType"] == "rejected" + assert result["error"] == "CUSTOM_TOOL_AUTHORIZATION_DENIED" + + +@pytest.mark.asyncio +async def test_guarded_tool_records_completion(): + events = [] + + async def original(invocation): + return { + "textResultForLlm": "ok", + "resultType": "success", + } + + tool = Tool( + name="inspect_state", + description="Test tool", + parameters={"type": "object"}, + handler=original, + ) + guarded = wrap_sovrint_tool( + tool, + SOVRINT_READ_ONLY_PROFILE, + audit_sink=events.append, + authorize=lambda invocation: True, + ) + + result = await guarded.handler( + { + "session_id": "session-7", + "tool_call_id": "tool-call-2", + "tool_name": "inspect_state", + "arguments": {}, + } + ) + + assert result["resultType"] == "success" + assert [event["eventClass"] for event in events] == [ + "TOOL_INVOCATION_STARTED", + "TOOL_INVOCATION_APPROVED", + "TOOL_INVOCATION_COMPLETED", + ] + assert all("arguments" not in event and "result" not in event for event in events) diff --git a/sovrint/examples/audit-event.example.json b/sovrint/examples/audit-event.example.json new file mode 100644 index 0000000000..7baba3899e --- /dev/null +++ b/sovrint/examples/audit-event.example.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": "1.0", + "eventId": "SOVRINT-AUDIT-0001", + "parentEventId": null, + "eventClass": "PERMISSION_DECISION", + "timestampUtc": "2026-06-26T23:55:00Z", + "profileId": "sovrint.read-only", + "profileVersion": "1.0", + "sessionId": "SIMULATION-SESSION-001", + "toolCallId": "SIMULATION-TOOL-CALL-001", + "toolName": null, + "permissionKind": "write", + "decision": "DENIED", + "reasonCode": "PROFILE_EXPLICIT_DENY", + "targetClass": "filesystem", + "scopeCommitment": "1111111111111111111111111111111111111111111111111111111111111111", + "governanceReference": null, + "integrityReference": null, + "evidenceReference": null, + "disclosureClass": "INTERNAL", + "evidenceStatus": "NOT_SUBMITTED", + "metadata": { + "simulationOnly": true, + "requestMetadataIncluded": false + } +} diff --git a/sovrint/manifest.yaml b/sovrint/manifest.yaml new file mode 100644 index 0000000000..486a8f59d7 --- /dev/null +++ b/sovrint/manifest.yaml @@ -0,0 +1,60 @@ +schema_version: "1.0" +extension_id: "sovrint-governed-copilot-sdk" +version: "1.0" +author: "Katrina Pietroniro" +framework: "SOVRINT™" +status: "additive-extension" +upstream_protocol_modified: false + +components: + documentation: + - docs/sovrint/README.md + - docs/sovrint/SECURITY_PROFILE.md + - docs/sovrint/GOVERNANCE_INTEGRITY_EVIDENCE.md + - docs/sovrint/API_REFERENCE.md + - docs/sovrint/DEPLOYMENT_CHECKLIST.md + - SOVRINT_EXTENSION.md + - SOVRINT_EXTENSION_ATTRIBUTION.md + - SOVRINT_CHANGELOG.md + common_schemas: + - sovrint/schemas/security-profile.schema.json + - sovrint/schemas/audit-event.schema.json + profiles: + - sovrint/profiles/strict.json + - sovrint/profiles/read-only.json + - sovrint/profiles/research-template.yaml + policies: + - sovrint/policy/permission-matrix.yaml + - sovrint/policy/audit-event-taxonomy.yaml + examples: + - sovrint/examples/audit-event.example.json + language_helpers: + - nodejs/src/sovrint.ts + - python/copilot/sovrint.py + - go/sovrint.go + language_tests: + - nodejs/test/sovrint.test.ts + - python/test_sovrint.py + - go/sovrint_test.go + recipes: + - cookbook/nodejs/sovrint-governed-session.md + - cookbook/python/sovrint-governed-session.md + - cookbook/go/sovrint-governed-session.md + - cookbook/dotnet/sovrint-governed-session.md + validation: + - .github/workflows/sovrint-extension-validate.yml + +interfaces: + governance_runtime: "SOVRINT-OG/sovrint-governance-runtime" + integrity_engine: "SOVRINT-OG/integrity-engine" + provenance_ledger: "SOVRINT-OG/sovrint-provenance-ledger" + federation_runtime: "SOVRINT-OG/sovrint-federation-runtime" + +invariants: + - "The upstream Copilot SDK JSON-RPC protocol remains unchanged." + - "Existing SDK users are unaffected unless they import and apply the SOVRINT helpers." + - "System-message replacement is forbidden by built-in SOVRINT profiles." + - "Unknown permission kinds are denied." + - "The most restrictive composed permission decision wins." + - "Audit events are not represented as EvidenceGrid acceptance." + - "The extension does not create governance authority." diff --git a/sovrint/policy/audit-event-taxonomy.yaml b/sovrint/policy/audit-event-taxonomy.yaml new file mode 100644 index 0000000000..c4b66234c9 --- /dev/null +++ b/sovrint/policy/audit-event-taxonomy.yaml @@ -0,0 +1,59 @@ +schema_version: "1.0" +author: "Katrina Pietroniro" +framework: "SOVRINT™" + +event_classes: + PROFILE_APPLIED: + decision_values: [RECORDED] + PERMISSION_DECISION: + decision_values: [APPROVED, DENIED] + TOOL_INVOCATION_STARTED: + decision_values: [PENDING] + TOOL_INVOCATION_APPROVED: + decision_values: [APPROVED] + TOOL_INVOCATION_REJECTED: + decision_values: [REJECTED] + TOOL_INVOCATION_COMPLETED: + decision_values: [RECORDED] + TOOL_INVOCATION_FAILED: + decision_values: [FAILED] + SYSTEM_MESSAGE_REPLACE_REJECTED: + decision_values: [DENIED] + POLICY_VIOLATION: + decision_values: [DENIED, REJECTED, FAILED] + AUDIT_SINK_FAILURE: + decision_values: [FAILED] + EVIDENCE_SUBMISSION_STATUS: + decision_values: [PENDING, RECORDED, FAILED] + +reason_codes: + - UNKNOWN_PERMISSION_KIND + - PROFILE_EXPLICIT_DENY + - PROFILE_EXPLICIT_ALLOW + - PROFILE_DEFAULT_DENY + - PROFILE_DEFAULT_ALLOW + - APPLICATION_EVALUATOR_APPROVED + - APPLICATION_EVALUATOR_DENIED + - APPLICATION_EVALUATOR_FAILED + - DOWNSTREAM_HANDLER_DENIED + - DOWNSTREAM_HANDLER_FAILED + - AUDIT_SINK_UNAVAILABLE + - TOOL_INVOCATION_RECEIVED + - CUSTOM_TOOL_AUTHORIZED + - CUSTOM_TOOL_AUTHORIZATION_DENIED + - CUSTOM_TOOL_COMPLETED + - CUSTOM_TOOL_FAILED + +privacy: + include_prompts: false + include_responses: false + include_tool_arguments: false + include_tool_results: false + include_credentials: false + include_raw_file_contents: false + +invariants: + - "Every event carries a profile identifier and version." + - "Every permission decision carries a reason code." + - "Tool events carry a session and tool-call reference." + - "Audit events are not represented as EvidenceGrid blocks." diff --git a/sovrint/policy/permission-matrix.yaml b/sovrint/policy/permission-matrix.yaml new file mode 100644 index 0000000000..d20aa1b0ab --- /dev/null +++ b/sovrint/policy/permission-matrix.yaml @@ -0,0 +1,52 @@ +schema_version: "1.0" +author: "Katrina Pietroniro" +framework: "SOVRINT™" + +permission_kinds: + read: + strict: DENY + read_only: APPROVE + research: APPROVE + notes: "Application-specific path and data classification checks may still deny." + write: + strict: DENY + read_only: DENY + research: EVALUATE + notes: "Requires explicit scope and application policy." + shell: + strict: DENY + read_only: DENY + research: EVALUATE + notes: "Requires explicit command and environment policy." + url: + strict: DENY + read_only: DENY + research: EVALUATE + notes: "Requires destination and disclosure policy." + mcp: + strict: DENY + read_only: DENY + research: EVALUATE + notes: "Requires named server and named tool policy." + +precedence: + - EXPLICIT_DENY + - APPLICATION_EVALUATOR + - PROFILE_ALLOW + - PROFILE_DEFAULT + - EXISTING_HANDLER + - FINAL_DECISION + +unknown_kind: DENY + +composition: + rule: "Most restrictive decision wins." + existing_handler_denial_preserved: true + evaluator_error: DENY + audit_failure_when_fail_closed: DENY + +invariants: + - "Permission approval is session-scoped unless a narrower scope is declared." + - "A local approval is not a global governance decision." + - "Tool availability and permission approval are separate gates." + - "Unknown fields are not treated as proof of safe scope." diff --git a/sovrint/profiles/read-only.json b/sovrint/profiles/read-only.json new file mode 100644 index 0000000000..082f2a1b2e --- /dev/null +++ b/sovrint/profiles/read-only.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "1.0", + "profileId": "sovrint.read-only", + "version": "1.0", + "description": "Read-oriented governed-session profile.", + "defaultDecision": "deny", + "allowKinds": ["read"], + "denyKinds": ["write", "shell", "url", "mcp"], + "availableTools": [], + "excludedTools": [], + "forbidSystemMessageReplace": true, + "failClosedOnAuditError": true, + "systemMessageAppend": "Operate in read-only mode. Do not mutate files, execute shell commands, access external URLs, or invoke MCP operations without a separate explicit approval path.", + "audit": { + "enabled": true, + "includeRequestMetadata": true, + "includeArguments": false, + "includeResults": false + }, + "metadata": { + "toolSurfaceMode": "inherit" + } +} diff --git a/sovrint/profiles/research-template.yaml b/sovrint/profiles/research-template.yaml new file mode 100644 index 0000000000..486bd2e99f --- /dev/null +++ b/sovrint/profiles/research-template.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +profile_id: "sovrint.research" +version: "1.0" +default_decision: "deny" +allow_kinds: + - read +deny_kinds: [] +tool_surface_mode: "inherit" +forbid_system_message_replace: true +fail_closed_on_audit_error: false +requires_application_evaluator: + - write + - shell + - url + - mcp +audit: + enabled: true + include_arguments: false + include_results: false +notes: + - "Returning no evaluator decision defers to the profile default." + - "Local approval remains local to the application and session." + - "Research observations remain distinct from verified findings." diff --git a/sovrint/profiles/strict.json b/sovrint/profiles/strict.json new file mode 100644 index 0000000000..9c13a7bad0 --- /dev/null +++ b/sovrint/profiles/strict.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "1.0", + "profileId": "sovrint.strict", + "version": "1.0", + "description": "Conservative governed-session profile.", + "defaultDecision": "deny", + "allowKinds": [], + "denyKinds": ["read", "write", "shell", "url", "mcp"], + "availableTools": [], + "excludedTools": [], + "forbidSystemMessageReplace": true, + "failClosedOnAuditError": true, + "systemMessageAppend": "Use only explicitly exposed tools and preserve governance, integrity, and evidence boundaries.", + "audit": { + "enabled": true, + "includeRequestMetadata": true, + "includeArguments": false, + "includeResults": false + }, + "metadata": { + "toolSurfaceMode": "none" + } +} diff --git a/sovrint/schemas/audit-event.schema.json b/sovrint/schemas/audit-event.schema.json new file mode 100644 index 0000000000..5ca14e6196 --- /dev/null +++ b/sovrint/schemas/audit-event.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sovrint.local/schemas/copilot-sdk-audit-event.schema.json", + "title": "SOVRINT Governed Copilot SDK Audit Event", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "eventId", + "eventClass", + "timestampUtc", + "profileId", + "profileVersion", + "sessionId", + "decision", + "reasonCode", + "disclosureClass", + "evidenceStatus" + ], + "properties": { + "schemaVersion": {"const": "1.0"}, + "eventId": {"type": "string", "minLength": 1}, + "parentEventId": {"type": ["string", "null"]}, + "eventClass": { + "type": "string", + "enum": [ + "PROFILE_APPLIED", + "PERMISSION_DECISION", + "TOOL_INVOCATION_STARTED", + "TOOL_INVOCATION_APPROVED", + "TOOL_INVOCATION_REJECTED", + "TOOL_INVOCATION_COMPLETED", + "TOOL_INVOCATION_FAILED", + "SYSTEM_MESSAGE_REPLACE_REJECTED", + "POLICY_VIOLATION", + "AUDIT_SINK_FAILURE", + "EVIDENCE_SUBMISSION_STATUS" + ] + }, + "timestampUtc": {"type": "string", "format": "date-time"}, + "profileId": {"type": "string", "minLength": 1}, + "profileVersion": {"type": "string", "minLength": 1}, + "sessionId": {"type": "string", "minLength": 1}, + "toolCallId": {"type": ["string", "null"]}, + "toolName": {"type": ["string", "null"]}, + "permissionKind": { + "type": ["string", "null"], + "enum": [null, "read", "write", "shell", "url", "mcp", "unknown"] + }, + "decision": { + "type": "string", + "enum": ["APPROVED", "DENIED", "REJECTED", "FAILED", "RECORDED", "PENDING"] + }, + "reasonCode": {"type": "string", "minLength": 1}, + "targetClass": {"type": ["string", "null"]}, + "scopeCommitment": { + "type": ["string", "null"], + "pattern": "^[A-Fa-f0-9]{64}$" + }, + "governanceReference": {"type": ["string", "null"]}, + "integrityReference": {"type": ["string", "null"]}, + "evidenceReference": {"type": ["string", "null"]}, + "disclosureClass": { + "type": "string", + "enum": ["PUBLIC", "INTERNAL", "PROTECTED", "RESTRICTED"] + }, + "evidenceStatus": { + "type": "string", + "enum": ["NOT_SUBMITTED", "SUBMISSION_PENDING", "SUBMITTED", "ACCEPTED", "REJECTED", "QUARANTINED", "RETRYABLE"] + }, + "metadata": {"type": "object"} + } +} diff --git a/sovrint/schemas/security-profile.schema.json b/sovrint/schemas/security-profile.schema.json new file mode 100644 index 0000000000..c13eeaa8ac --- /dev/null +++ b/sovrint/schemas/security-profile.schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sovrint.local/schemas/copilot-sdk-security-profile.schema.json", + "title": "SOVRINT Governed Copilot SDK Security Profile", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "profileId", + "version", + "defaultDecision", + "allowKinds", + "denyKinds", + "availableTools", + "excludedTools", + "forbidSystemMessageReplace", + "failClosedOnAuditError", + "systemMessageAppend", + "audit" + ], + "properties": { + "schemaVersion": { + "const": "1.0" + }, + "profileId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{2,63}$" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+(?:\\.[0-9]+)?$" + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "defaultDecision": { + "type": "string", + "enum": ["approve", "deny"] + }, + "allowKinds": { + "$ref": "#/$defs/permissionKinds" + }, + "denyKinds": { + "$ref": "#/$defs/permissionKinds" + }, + "availableTools": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "excludedTools": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "forbidSystemMessageReplace": { + "type": "boolean" + }, + "failClosedOnAuditError": { + "type": "boolean" + }, + "systemMessageAppend": { + "type": "string", + "minLength": 1, + "maxLength": 12000 + }, + "audit": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "includeRequestMetadata", "includeArguments", "includeResults"], + "properties": { + "enabled": {"type": "boolean"}, + "includeRequestMetadata": {"type": "boolean"}, + "includeArguments": {"const": false}, + "includeResults": {"const": false} + } + }, + "mcp": { + "type": "object", + "additionalProperties": false, + "properties": { + "allowedServers": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "requireNamedToolAllowlist": { + "type": "boolean" + } + } + }, + "skills": { + "type": "object", + "additionalProperties": false, + "properties": { + "allowedDirectories": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "disabledSkills": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + } + } + }, + "metadata": { + "type": "object" + } + }, + "allOf": [ + { + "not": { + "properties": { + "allowKinds": { + "contains": {"enum": ["shell", "write", "mcp", "url"]} + }, + "defaultDecision": {"const": "approve"} + }, + "required": ["allowKinds", "defaultDecision"] + } + } + ], + "$defs": { + "permissionKinds": { + "type": "array", + "items": { + "type": "string", + "enum": ["read", "write", "shell", "url", "mcp"] + }, + "uniqueItems": true + } + } +}