Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd445a9448 |
@@ -20,6 +20,14 @@ BASE_MODELS = [
|
||||
]
|
||||
SECRET_RE = re.compile(r'(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)\s*[:=]\s*["\']?([A-Za-z0-9_./+=:-]{16,})')
|
||||
SECRET_VAL_RE = re.compile(r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})')
|
||||
SECRET_TEXT_RE = re.compile(r'(?i)(Bearer\\s+[A-Za-z0-9._-]{3,}|BEGIN\\s+PRIVATE\\s+KEY|password\\s*=\\s*\\S+|api_key\\s*=\\s*\\S+|access_token\\s*=\\s*\\S+|refresh_token\\s*=\\s*\\S+)')
|
||||
SECRET_PATH_PREFIXES = ('runtime/secrets/', 'private/', 'prod-secrets/')
|
||||
LOW_RISK_FILE_SCOPES = {
|
||||
'/api/gitops/report-file': ('commitReportFile', ('reports/',)),
|
||||
'/api/gitops/docs-file': ('commitDocsFile', ('docs/',)),
|
||||
'/api/gitops/proposal-file': ('commitProposalFile', ('proposals/',)),
|
||||
'/api/gitops/patch-evidence': ('commitPatchEvidence', ('reports/', 'patches/')),
|
||||
}
|
||||
|
||||
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
||||
@@ -35,6 +43,15 @@ def relpath(path: str) -> str:
|
||||
x = str(path or '').replace('\\','/').strip()
|
||||
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
||||
return str(Path(x))
|
||||
|
||||
def secret_path(path: str) -> bool:
|
||||
r = relpath(path).replace('\\', '/')
|
||||
name = Path(r).name
|
||||
return name.endswith('.token') or r == '.env' or any(r.startswith(prefix) for prefix in SECRET_PATH_PREFIXES)
|
||||
|
||||
def path_in_scope(path: str, scopes: tuple[str, ...]) -> bool:
|
||||
r = relpath(path).replace('\\', '/')
|
||||
return any(r.startswith(scope) for scope in scopes)
|
||||
def branch_name(name: str | None, prefix='patch/chatgpt') -> str:
|
||||
b = safe(name or f'{prefix}-{uid("br")}', 180)
|
||||
if b in ('main','master') or b.startswith('main/') or b.startswith('master/'): raise ValueError('direct main/master writes are forbidden')
|
||||
@@ -63,7 +80,7 @@ def content_has_secret(text: str) -> bool:
|
||||
return _json_has_plain_secret(json.loads(text))
|
||||
except Exception:
|
||||
pass
|
||||
if SECRET_VAL_RE.search(text):
|
||||
if SECRET_VAL_RE.search(text) or SECRET_TEXT_RE.search(text):
|
||||
return True
|
||||
for m in SECRET_RE.finditer(text):
|
||||
key = str(m.group(1)).lower()
|
||||
@@ -109,8 +126,10 @@ def checkout(branch, base='main', create=False):
|
||||
else: sh(['checkout',b], True)
|
||||
return b
|
||||
def write_file(path, content):
|
||||
r=relpath(path)
|
||||
if secret_path(r): raise ValueError('secret_path_rejected: use runtime secret store or secret_ref')
|
||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
||||
t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
||||
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
||||
def sync_branch(branch: str) -> dict:
|
||||
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
||||
@@ -201,6 +220,9 @@ class H(BaseHTTPRequestHandler):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||
try:
|
||||
if path == '/api/branch': return self.branch(data)
|
||||
if path in LOW_RISK_FILE_SCOPES: return self.low_risk_file(path, data)
|
||||
if path == '/api/gitops/main-file': return self.main_file(data)
|
||||
if path == '/api/gitops/rollback-request': return self.rollback(data)
|
||||
if path == '/api/file': return self.file(data)
|
||||
if path == '/api/commit': return self.commit(data)
|
||||
if path == '/api/patch': return self.patch(data)
|
||||
@@ -220,6 +242,40 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch'), data.get('base','main'), bool(data.get('create_branch'))); r=write_file(data.get('path'), data.get('content','')); c=commit([r], data.get('message') or f'chore(chatgpt): update {r}'); audit(a['account'],'file',r,{'branch':b,'commit':c}); self.sendj({'status':'committed','branch':b,'path':r,'commit':c})
|
||||
def low_risk_file(self,path,data):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
operation_id, scopes = LOW_RISK_FILE_SCOPES[path]
|
||||
requested_branch = data.get('branch')
|
||||
if not requested_branch or branch_name(requested_branch) != requested_branch:
|
||||
return self.sendj({'error':'low-risk write requires explicit non-main patch/request branch','operationId':operation_id},400)
|
||||
r=relpath(data.get('path'))
|
||||
if secret_path(r):
|
||||
return self.sendj({'error':'Secret-like path rejected. Use runtime secret store or secret_ref.','operationId':operation_id},400)
|
||||
if not path_in_scope(r, scopes):
|
||||
return self.sendj({'error':'path_out_of_scope','operationId':operation_id,'allowed_scopes':list(scopes)},400)
|
||||
if content_has_secret(data.get('content','')):
|
||||
return self.sendj({'error':'Secret-like content detected. Use runtime secret store or secret_ref.','operationId':operation_id},400)
|
||||
b=checkout(requested_branch, data.get('base','main'), bool(data.get('create_branch')))
|
||||
r=write_file(r, data.get('content',''))
|
||||
c=commit([r], data.get('message') or f'chore(chatgpt): {operation_id} {r}')
|
||||
audit(a['account'],operation_id,r,{'branch':b,'commit':c})
|
||||
self.sendj({'status':'committed','operationId':operation_id,'branch':b,'path':r,'commit':c})
|
||||
def main_file(self,data):
|
||||
a=self.need('file:main');
|
||||
if not a: return
|
||||
if a.get('role') != 'owner':
|
||||
return self.sendj({'error':'owner approval required for commitMainFile'},403)
|
||||
r=relpath(data.get('path'))
|
||||
if secret_path(r):
|
||||
return self.sendj({'error':'Secret-like path rejected. Use runtime secret store or secret_ref.','operationId':'commitMainFile'},400)
|
||||
if content_has_secret(data.get('content','')):
|
||||
return self.sendj({'error':'Secret-like content detected. Use runtime secret store or secret_ref.','operationId':'commitMainFile'},400)
|
||||
ensure_git(); sh(['checkout','main'], True)
|
||||
r=write_file(r, data.get('content',''))
|
||||
c=commit([r], data.get('message') or f'chore(chatgpt): commit main file {r}')
|
||||
audit(a['account'],'commitMainFile',r,{'branch':'main','commit':c})
|
||||
self.sendj({'status':'committed','operationId':'commitMainFile','branch':'main','path':r,'commit':c})
|
||||
def commit(self,data):
|
||||
a=self.need('commit:create');
|
||||
if not a: return
|
||||
@@ -270,7 +326,7 @@ class H(BaseHTTPRequestHandler):
|
||||
b=checkout(data.get('branch') or f'patch/nl-{uid("nl")}', 'main', True); doc={'id':uid('nl'),'text':text,'intent':intent,'action_plan':plan,'execute':False,'owner_approval_required':True,'created_by':a['account'],'created_at':now()}; r=write_json(f'patches/nl/{safe(doc["id"])}.json', doc); c=commit([r], f'chore(chatgpt): record nl plan {intent}'); self.sendj({'status':'planned','branch':b,'request':doc,'commit':c})
|
||||
|
||||
def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],'write':['POST /api/branch','POST /api/file','POST /api/gitops/report-file','POST /api/gitops/docs-file','POST /api/gitops/proposal-file','POST /api/gitops/patch-evidence','POST /api/gitops/main-file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/gitops/rollback-request','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'low_risk_main_write':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||
|
||||
def main():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# GPT Actions Consequential Governance
|
||||
|
||||
## Objective
|
||||
|
||||
Reduce unnecessary ChatGPT Actions confirmation prompts for low-risk GitOps write operations while preserving confirmation protection for high-risk changes.
|
||||
|
||||
## Platform Boundary
|
||||
|
||||
Instructions can reduce how often a Custom GPT chooses high-risk actions, but they cannot guarantee that the ChatGPT platform will suppress every external Action confirmation prompt. The platform may still ask the user to confirm writes to external services.
|
||||
|
||||
`x-openai-isConsequential` is the OpenAPI-level control used to communicate whether an Action is consequential:
|
||||
|
||||
- Low-risk GitOps evidence and documentation writes use `x-openai-isConsequential: false`.
|
||||
- High-risk runtime, rollback, main-branch, tag, release, baseline, authentication, permission, and production-model changes use `x-openai-isConsequential: true`.
|
||||
|
||||
Fully unattended background writes should be executed by the AIOS Orchestrator, not by direct Custom GPT UI Actions.
|
||||
|
||||
## Low-Risk Actions
|
||||
|
||||
Low-risk Actions are limited to non-main patch branches and controlled evidence paths:
|
||||
|
||||
| operationId | Consequential | Path Scope |
|
||||
|---|---:|---|
|
||||
| `commitReportFile` | `false` | `reports/**` |
|
||||
| `commitDocsFile` | `false` | `docs/**` |
|
||||
| `commitProposalFile` | `false` | `proposals/**` |
|
||||
| `commitPatchEvidence` | `false` | `reports/**`, `patches/**` |
|
||||
|
||||
Low-risk Actions must reject:
|
||||
|
||||
- `main` and `master` writes.
|
||||
- `runtime/secrets/**`.
|
||||
- `.env`.
|
||||
- `*.token`.
|
||||
- `private/**`.
|
||||
- `prod-secrets/**`.
|
||||
- Secret-like content such as credential values, passwords, API keys, private keys, or access tokens.
|
||||
- Merge, rollback, tag, release, runtime, production, baseline, permission, or authentication mutations.
|
||||
|
||||
## High-Risk Actions
|
||||
|
||||
High-risk Actions intentionally keep confirmation protection:
|
||||
|
||||
| operationId | Consequential | Reason |
|
||||
|---|---:|---|
|
||||
| `commitFile` | `true` | Legacy generic write API retained for compatibility. |
|
||||
| `commitMainFile` | `true` | Explicit main-branch write. |
|
||||
| `createRollbackRequest` | `true` | Rollback-related workflow. |
|
||||
| `requestRollback` | `true` | Legacy rollback request compatibility. |
|
||||
| `requestOrCreateTag` | `true` | Release/tag workflow. |
|
||||
| `setModelPriority` | `true` | Model-routing policy mutation. |
|
||||
| `registerModel` | `true` | Runtime model registry mutation. |
|
||||
|
||||
## Compatibility
|
||||
|
||||
The legacy `commitFile` operation is preserved and marked consequential. Custom GPTs should prefer the low-risk specialized operations for docs, reports, proposals, and patch evidence.
|
||||
|
||||
## Secret Handling
|
||||
|
||||
The schema uses placeholders only. Action authentication remains injected by GPT Builder Actions configuration at runtime. Real tokens, passwords, API keys, private keys, and secret values must never be placed in OpenAPI examples, docs, reports, tests, or scripts.
|
||||
|
||||
## Validation
|
||||
|
||||
Validation is covered by:
|
||||
|
||||
- `tests/test_openapi_consequential_flags.py`.
|
||||
- `tests/test_gitops_low_risk_commit_policy.py`.
|
||||
- `scripts/smoke_gpt_actions_consequential_governance.py`.
|
||||
|
||||
These checks validate the consequential matrix, path scoping, main-branch rejection for low-risk writes, secret-path rejection, secret-like content rejection, and placeholder-only schema examples.
|
||||
@@ -2,9 +2,11 @@ openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS GitOps Controlled Access API
|
||||
version: V1R1C0-chatgpt-actions
|
||||
description: Controlled ChatGPT Actions API for AIOS online GitOps baseline. Read endpoints are public. Write endpoints require Bearer token and create Git commits on non-main branches.
|
||||
description: Controlled ChatGPT Actions API for AIOS online GitOps baseline. Read
|
||||
endpoints are public. Write endpoints require Bearer token and create Git commits
|
||||
on non-main branches.
|
||||
servers:
|
||||
- url: https://cnnetmkt.top
|
||||
- url: https://cnnetmkt.top
|
||||
security: []
|
||||
components:
|
||||
securitySchemes:
|
||||
@@ -15,226 +17,603 @@ components:
|
||||
schemas:
|
||||
BranchRequest:
|
||||
type: object
|
||||
required: [branch]
|
||||
required:
|
||||
- branch
|
||||
properties:
|
||||
branch: {type: string, example: patch/chatgpt-actions-demo}
|
||||
base: {type: string, default: main}
|
||||
message: {type: string}
|
||||
branch:
|
||||
type: string
|
||||
example: patch/chatgpt-actions-demo
|
||||
base:
|
||||
type: string
|
||||
default: main
|
||||
message:
|
||||
type: string
|
||||
FileCommitRequest:
|
||||
type: object
|
||||
required: [branch, path, content]
|
||||
required:
|
||||
- branch
|
||||
- path
|
||||
- content
|
||||
properties:
|
||||
branch: {type: string}
|
||||
base: {type: string, default: main}
|
||||
create_branch: {type: boolean, default: false}
|
||||
path: {type: string, example: docs/gitops/chatgpt-change.md}
|
||||
content: {type: string}
|
||||
message: {type: string}
|
||||
branch:
|
||||
type: string
|
||||
base:
|
||||
type: string
|
||||
default: main
|
||||
create_branch:
|
||||
type: boolean
|
||||
default: false
|
||||
path:
|
||||
type: string
|
||||
example: docs/gitops/chatgpt-change.md
|
||||
content:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
CommitRequest:
|
||||
type: object
|
||||
properties:
|
||||
branch: {type: string}
|
||||
branch:
|
||||
type: string
|
||||
paths:
|
||||
type: array
|
||||
items: {type: string}
|
||||
message: {type: string}
|
||||
items:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
PatchRequest:
|
||||
type: object
|
||||
required: [patch_name]
|
||||
required:
|
||||
- patch_name
|
||||
properties:
|
||||
patch_name: {type: string}
|
||||
branch: {type: string}
|
||||
base: {type: string, default: main}
|
||||
risk: {type: string, enum: [low, medium, high]}
|
||||
description: {type: string}
|
||||
message: {type: string}
|
||||
patch_name:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
base:
|
||||
type: string
|
||||
default: main
|
||||
risk:
|
||||
type: string
|
||||
enum:
|
||||
- low
|
||||
- medium
|
||||
- high
|
||||
description:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
TagRequest:
|
||||
type: object
|
||||
required: [version]
|
||||
required:
|
||||
- version
|
||||
properties:
|
||||
version: {type: string, example: V1R1C1}
|
||||
message: {type: string}
|
||||
version:
|
||||
type: string
|
||||
example: V1R1C1
|
||||
message:
|
||||
type: string
|
||||
execute:
|
||||
type: boolean
|
||||
description: Owner-approved calls only. Otherwise an approval request commit is created.
|
||||
description: Owner-approved calls only. Otherwise an approval request commit
|
||||
is created.
|
||||
RollbackRequest:
|
||||
type: object
|
||||
required: [target_version]
|
||||
required:
|
||||
- target_version
|
||||
properties:
|
||||
target_version: {type: string}
|
||||
reason: {type: string}
|
||||
branch: {type: string}
|
||||
target_version:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
ModelRequest:
|
||||
type: object
|
||||
required: [provider_name, base_url, model_name]
|
||||
required:
|
||||
- provider_name
|
||||
- base_url
|
||||
- model_name
|
||||
properties:
|
||||
username: {type: string}
|
||||
provider_name: {type: string}
|
||||
base_url: {type: string}
|
||||
model_name: {type: string}
|
||||
display_name: {type: string}
|
||||
username:
|
||||
type: string
|
||||
provider_name:
|
||||
type: string
|
||||
base_url:
|
||||
type: string
|
||||
model_name:
|
||||
type: string
|
||||
display_name:
|
||||
type: string
|
||||
api_key_secret_ref:
|
||||
type: string
|
||||
description: Preferred. Git stores this secret_ref only.
|
||||
api_key:
|
||||
type: string
|
||||
writeOnly: true
|
||||
description: Accepted only for runtime secret storage; never committed to Git or logs.
|
||||
branch: {type: string}
|
||||
message: {type: string}
|
||||
description: Accepted only for runtime secret storage; never committed to
|
||||
Git or logs.
|
||||
branch:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
ModelPriorityRequest:
|
||||
type: object
|
||||
required: [items]
|
||||
required:
|
||||
- items
|
||||
properties:
|
||||
username: {type: string}
|
||||
branch: {type: string}
|
||||
username:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
model_id: {type: string}
|
||||
priority: {type: integer}
|
||||
enabled: {type: boolean}
|
||||
fallback_policy: {type: string}
|
||||
model_id:
|
||||
type: string
|
||||
priority:
|
||||
type: integer
|
||||
enabled:
|
||||
type: boolean
|
||||
fallback_policy:
|
||||
type: string
|
||||
NLRequest:
|
||||
type: object
|
||||
required: [text]
|
||||
required:
|
||||
- text
|
||||
properties:
|
||||
text: {type: string}
|
||||
branch: {type: string}
|
||||
text:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
LowRiskFileCommitRequest:
|
||||
type: object
|
||||
required:
|
||||
- branch
|
||||
- path
|
||||
- content
|
||||
- message
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
description: Non-main patch branch. Must start with patch/ or request/.
|
||||
example: patch/gpt-actions-consequential-governance
|
||||
base:
|
||||
type: string
|
||||
default: main
|
||||
description: Base branch used when create_branch is true.
|
||||
create_branch:
|
||||
type: boolean
|
||||
default: false
|
||||
path:
|
||||
type: string
|
||||
description: File path constrained by the operation path scope.
|
||||
example: reports/example-report.md
|
||||
content:
|
||||
type: string
|
||||
description: File content. Must not include secrets, credentials, passwords,
|
||||
API keys, private keys, or token values.
|
||||
message:
|
||||
type: string
|
||||
description: Commit message. Must not include secrets or credentials.
|
||||
example: 'docs(actions): add GPT Actions governance evidence'
|
||||
MainFileCommitRequest:
|
||||
type: object
|
||||
required:
|
||||
- path
|
||||
- content
|
||||
- message
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
default: main
|
||||
description: Main branch. This high-risk operation requires explicit owner
|
||||
intent.
|
||||
path:
|
||||
type: string
|
||||
description: File path for explicit main-branch maintenance. Secret paths
|
||||
are always rejected.
|
||||
content:
|
||||
type: string
|
||||
description: File content. Must not include secrets, credentials, passwords,
|
||||
API keys, private keys, or token values.
|
||||
message:
|
||||
type: string
|
||||
description: Commit message. Must not include secrets or credentials.
|
||||
RollbackRequestAction:
|
||||
type: object
|
||||
required:
|
||||
- target_version
|
||||
- reason
|
||||
properties:
|
||||
target_version:
|
||||
type: string
|
||||
example: V1R1C4
|
||||
reason:
|
||||
type: string
|
||||
example: Owner-approved rollback request
|
||||
branch:
|
||||
type: string
|
||||
example: request/rollback-V1R1C4
|
||||
paths:
|
||||
/api/base:
|
||||
get:
|
||||
operationId: getBaseline
|
||||
summary: Read current AIOS baseline
|
||||
responses: {'200': {description: OK}}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/api/ver:
|
||||
get:
|
||||
operationId: listVersions
|
||||
summary: List GitOps versions
|
||||
responses: {'200': {description: OK}}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/api/diff:
|
||||
get:
|
||||
operationId: getDiff
|
||||
summary: Read Git diff between refs
|
||||
parameters:
|
||||
- in: query
|
||||
name: from
|
||||
schema: {type: string, default: HEAD~1}
|
||||
- in: query
|
||||
name: to
|
||||
schema: {type: string, default: HEAD}
|
||||
responses: {'200': {description: OK}}
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
type: string
|
||||
default: HEAD~1
|
||||
- in: query
|
||||
name: to
|
||||
schema:
|
||||
type: string
|
||||
default: HEAD
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/api/patch:
|
||||
get:
|
||||
operationId: listPatches
|
||||
summary: List patch records
|
||||
responses: {'200': {description: OK}}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
post:
|
||||
operationId: createPatch
|
||||
summary: Create a patch branch and commit metadata
|
||||
security: [{bearerAuth: []}]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PatchRequest'}
|
||||
responses: {'200': {description: Patch branch committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Patch branch committed
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'403':
|
||||
description: Forbidden
|
||||
x-openai-isConsequential: true
|
||||
/api/model:
|
||||
get:
|
||||
operationId: readModelRegistry
|
||||
summary: Read visible model registry and priority
|
||||
responses: {'200': {description: OK}}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
post:
|
||||
operationId: registerModel
|
||||
summary: Register a custom model; API keys are stored as runtime secrets and Git receives secret_ref only
|
||||
security: [{bearerAuth: []}]
|
||||
summary: Register a custom model; API keys are stored as runtime secrets and
|
||||
Git receives secret_ref only
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ModelRequest'}
|
||||
responses: {'200': {description: Model registered and committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/ModelRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Model registered and committed
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'403':
|
||||
description: Forbidden
|
||||
x-openai-isConsequential: true
|
||||
/api/run:
|
||||
get:
|
||||
operationId: getRuntimeStatus
|
||||
summary: Read runtime status
|
||||
responses: {'200': {description: OK}}
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/api/branch:
|
||||
post:
|
||||
operationId: createBranch
|
||||
summary: Create a non-main branch and commit branch metadata
|
||||
security: [{bearerAuth: []}]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BranchRequest'}
|
||||
responses: {'200': {description: Branch created}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/BranchRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Branch created
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'403':
|
||||
description: Forbidden
|
||||
x-openai-isConsequential: true
|
||||
/api/file:
|
||||
post:
|
||||
operationId: commitFile
|
||||
summary: Write a file and create a Git commit on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FileCommitRequest'}
|
||||
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/FileCommitRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: File committed
|
||||
'400':
|
||||
description: Secret rejected or invalid path
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-openai-isConsequential: true
|
||||
/api/commit:
|
||||
post:
|
||||
operationId: commitChanges
|
||||
summary: Commit existing workspace changes on a non-main branch
|
||||
security: [{bearerAuth: []}]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CommitRequest'}
|
||||
responses: {'200': {description: Commit result}, '401': {description: Unauthorized}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/CommitRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Commit result
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-openai-isConsequential: true
|
||||
/api/tag:
|
||||
post:
|
||||
operationId: requestOrCreateTag
|
||||
summary: Request tag creation; owner execution is required for direct tag
|
||||
security: [{bearerAuth: []}]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TagRequest'}
|
||||
responses: {'200': {description: Tag request committed or tag created}, '403': {description: Forbidden}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/TagRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Tag request committed or tag created
|
||||
'403':
|
||||
description: Forbidden
|
||||
x-openai-isConsequential: true
|
||||
/api/rollback:
|
||||
post:
|
||||
operationId: requestRollback
|
||||
summary: Commit a rollback request requiring owner/admin approval
|
||||
security: [{bearerAuth: []}]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RollbackRequest'}
|
||||
responses: {'200': {description: Rollback request committed}, '401': {description: Unauthorized}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/RollbackRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Rollback request committed
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-openai-isConsequential: true
|
||||
/api/model-priority:
|
||||
put:
|
||||
operationId: setModelPriority
|
||||
summary: Set model priority and commit policy file
|
||||
security: [{bearerAuth: []}]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ModelPriorityRequest'}
|
||||
responses: {'200': {description: Priority updated and committed}, '401': {description: Unauthorized}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/ModelPriorityRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Priority updated and committed
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-openai-isConsequential: true
|
||||
/api/nl:
|
||||
post:
|
||||
operationId: planNaturalLanguageGitOpsAction
|
||||
summary: Convert natural language to a safe committed plan without executing V-level changes
|
||||
security: [{bearerAuth: []}]
|
||||
summary: Convert natural language to a safe committed plan without executing
|
||||
V-level changes
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/NLRequest'}
|
||||
responses: {'200': {description: Plan committed}, '401': {description: Unauthorized}}
|
||||
schema:
|
||||
$ref: '#/components/schemas/NLRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Plan committed
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-openai-isConsequential: true
|
||||
/api/gitops/report-file:
|
||||
post:
|
||||
operationId: commitReportFile
|
||||
x-openai-isConsequential: false
|
||||
x-aios-path-scope: reports/**
|
||||
summary: Commit a report file to a controlled GitOps patch branch
|
||||
description: Low-risk report write. Commits markdown or JSON evidence under
|
||||
reports/** to a non-main patch branch. This operation must not write secrets,
|
||||
credentials, token values, runtime secret files, rollback requests, or production
|
||||
config.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LowRiskFileCommitRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: File committed successfully
|
||||
'400':
|
||||
description: Invalid branch, path, or secret-like content rejected
|
||||
'401':
|
||||
description: Authorization header was not injected
|
||||
'403':
|
||||
description: Authorization scope is insufficient
|
||||
/api/gitops/docs-file:
|
||||
post:
|
||||
operationId: commitDocsFile
|
||||
x-openai-isConsequential: false
|
||||
x-aios-path-scope: docs/**
|
||||
summary: Commit a docs file to a controlled GitOps patch branch
|
||||
description: Low-risk documentation write under docs/** on a non-main patch
|
||||
branch. It must not include real tokens, passwords, API keys, private keys,
|
||||
credentials, rollback execution, or production config mutation.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LowRiskFileCommitRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: File committed successfully
|
||||
'400':
|
||||
description: Invalid branch, path, or secret-like content rejected
|
||||
'401':
|
||||
description: Authorization header was not injected
|
||||
'403':
|
||||
description: Authorization scope is insufficient
|
||||
/api/gitops/proposal-file:
|
||||
post:
|
||||
operationId: commitProposalFile
|
||||
x-openai-isConsequential: false
|
||||
x-aios-path-scope: proposals/**
|
||||
summary: Commit a proposal file to a controlled GitOps patch branch
|
||||
description: Low-risk proposal write under proposals/** on a non-main patch
|
||||
branch. It must not include real tokens, passwords, API keys, private keys,
|
||||
credentials, runtime secret files, rollback execution, or production config
|
||||
mutation.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LowRiskFileCommitRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: File committed successfully
|
||||
'400':
|
||||
description: Invalid branch, path, or secret-like content rejected
|
||||
'401':
|
||||
description: Authorization header was not injected
|
||||
'403':
|
||||
description: Authorization scope is insufficient
|
||||
/api/gitops/patch-evidence:
|
||||
post:
|
||||
operationId: commitPatchEvidence
|
||||
x-openai-isConsequential: false
|
||||
x-aios-path-scope: reports/**, patches/**
|
||||
summary: Commit patch evidence to a controlled GitOps patch branch
|
||||
description: Low-risk evidence write under reports/** or patches/** on a non-main
|
||||
patch branch. It must not include real tokens, passwords, API keys, private
|
||||
keys, credentials, runtime secret files, rollback execution, or production
|
||||
config mutation.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LowRiskFileCommitRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: File committed successfully
|
||||
'400':
|
||||
description: Invalid branch, path, or secret-like content rejected
|
||||
'401':
|
||||
description: Authorization header was not injected
|
||||
'403':
|
||||
description: Authorization scope is insufficient
|
||||
/api/gitops/main-file:
|
||||
post:
|
||||
operationId: commitMainFile
|
||||
x-openai-isConsequential: true
|
||||
summary: Commit a file directly to main branch
|
||||
description: High-risk explicit main-branch write. This operation exists only
|
||||
for owner-approved maintenance and must not include real secrets, credentials,
|
||||
passwords, private keys, API keys, token values, or runtime secret paths.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainFileCommitRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: File committed successfully
|
||||
'400':
|
||||
description: Invalid path or secret-like content rejected
|
||||
'401':
|
||||
description: Authorization header was not injected
|
||||
'403':
|
||||
description: Authorization scope is insufficient or owner approval missing
|
||||
/api/gitops/rollback-request:
|
||||
post:
|
||||
operationId: createRollbackRequest
|
||||
x-openai-isConsequential: true
|
||||
summary: Create an owner-review rollback request
|
||||
description: High-risk rollback request. Creates auditable rollback intent and
|
||||
must never silently mutate production state or bypass owner/admin review.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RollbackRequestAction'
|
||||
responses:
|
||||
'200':
|
||||
description: Rollback request committed
|
||||
'400':
|
||||
description: Invalid rollback request or secret-like content rejected
|
||||
'401':
|
||||
description: Authorization header was not injected
|
||||
'403':
|
||||
description: Authorization scope is insufficient or owner approval missing
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# GPT Actions Consequential Governance Report
|
||||
|
||||
Status: READY_FOR_REVIEW
|
||||
|
||||
Branch: patch/gpt-actions-consequential-governance
|
||||
|
||||
Commit: see final OpenClaw response for immutable commit rev
|
||||
|
||||
## Objective
|
||||
|
||||
Reduce unnecessary ChatGPT Actions confirmation prompts for low-risk GitOps write operations while preserving confirmation protection for high-risk operations.
|
||||
|
||||
## Changes
|
||||
|
||||
### Low-risk Actions
|
||||
|
||||
| operationId | x-openai-isConsequential | Path Scope |
|
||||
|---|---:|---|
|
||||
| commitReportFile | false | reports/** |
|
||||
| commitDocsFile | false | docs/** |
|
||||
| commitProposalFile | false | proposals/** |
|
||||
| commitPatchEvidence | false | reports/**, patches/** |
|
||||
|
||||
### High-risk Actions
|
||||
|
||||
| operationId | x-openai-isConsequential |
|
||||
|---|---:|
|
||||
| commitFile | true |
|
||||
| commitMainFile | true |
|
||||
| createRollbackRequest | true |
|
||||
| requestRollback | true |
|
||||
| requestOrCreateTag | true |
|
||||
| setModelPriority | true |
|
||||
| registerModel | true |
|
||||
|
||||
## OpenAPI Operations Added
|
||||
|
||||
- commitReportFile
|
||||
- commitDocsFile
|
||||
- commitProposalFile
|
||||
- commitPatchEvidence
|
||||
- commitMainFile
|
||||
- createRollbackRequest
|
||||
|
||||
## OpenAPI Operations Updated
|
||||
|
||||
- commitFile marked `x-openai-isConsequential: true`.
|
||||
- commitChanges marked `x-openai-isConsequential: true`.
|
||||
- createPatch marked `x-openai-isConsequential: true`.
|
||||
- createBranch marked `x-openai-isConsequential: true`.
|
||||
- requestOrCreateTag marked `x-openai-isConsequential: true`.
|
||||
- requestRollback marked `x-openai-isConsequential: true`.
|
||||
- registerModel marked `x-openai-isConsequential: true`.
|
||||
- setModelPriority marked `x-openai-isConsequential: true`.
|
||||
- planNaturalLanguageGitOpsAction marked `x-openai-isConsequential: true`.
|
||||
|
||||
## Security Validation
|
||||
|
||||
- No real tokens added.
|
||||
- No secret values added.
|
||||
- OpenAPI examples use placeholder values only.
|
||||
- Action auth remains injected by GPT Builder Actions configuration.
|
||||
- Low-risk policy rejects `main`, `runtime/secrets/**`, `.env`, `*.token`, `private/**`, `prod-secrets/**`, and secret-like content.
|
||||
|
||||
## Tests
|
||||
|
||||
- pytest: not run; remote Python environment does not have the `pytest` module installed.
|
||||
- direct test runner: passed, 14 test functions.
|
||||
- smoke: passed.
|
||||
|
||||
## Smoke Result
|
||||
|
||||
`python3 scripts/smoke_gpt_actions_consequential_governance.py` passed.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
Discard patch branch before merge, or revert the governance commit after merge.
|
||||
|
||||
## Risk
|
||||
|
||||
Medium. The change affects GPT Builder Action behavior and write API affordances while preserving high-risk confirmation gates.
|
||||
|
||||
## Next Step
|
||||
|
||||
Review OpenAPI schema in GPT Builder Actions and retest whether low-risk commit operations show Always allow or reduced confirmation prompts.
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Policy helpers for GPT Actions GitOps write operations.
|
||||
|
||||
The helpers intentionally avoid runtime credentials. They are used by tests and
|
||||
smoke checks to keep the OpenAPI consequential-action split aligned with server
|
||||
side write policy requirements.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
|
||||
|
||||
LOW_RISK_PATH_SCOPES: dict[str, tuple[str, ...]] = {
|
||||
"commitReportFile": ("reports/",),
|
||||
"commitDocsFile": ("docs/",),
|
||||
"commitProposalFile": ("proposals/",),
|
||||
"commitPatchEvidence": ("reports/", "patches/"),
|
||||
}
|
||||
|
||||
HIGH_RISK_OPERATIONS: set[str] = {
|
||||
"commitFile",
|
||||
"commitMainFile",
|
||||
"createRollbackRequest",
|
||||
"requestRollback",
|
||||
"requestOrCreateTag",
|
||||
"commitRuntimeFile",
|
||||
"commitConfigFile",
|
||||
"mergeMain",
|
||||
"adjustProductionModelPriority",
|
||||
"setModelPriority",
|
||||
"registerModel",
|
||||
}
|
||||
|
||||
SECRET_PATH_PATTERNS: tuple[str, ...] = (
|
||||
"runtime/secrets/",
|
||||
".env",
|
||||
"private/",
|
||||
"prod-secrets/",
|
||||
)
|
||||
|
||||
SECRET_CONTENT_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"sk-[A-Za-z0-9_-]{8,}"),
|
||||
re.compile(r"Bearer\s+\S+", re.IGNORECASE),
|
||||
re.compile(r"BEGIN\s+PRIVATE\s+KEY", re.IGNORECASE),
|
||||
re.compile(r"password\s*=\s*\S+", re.IGNORECASE),
|
||||
re.compile(r"api_key\s*=\s*\S+", re.IGNORECASE),
|
||||
re.compile(r"access_token\s*=\s*\S+", re.IGNORECASE),
|
||||
re.compile(r"refresh_token\s*=\s*\S+", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyResult:
|
||||
allowed: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
raw = (path or "").replace("\\", "/").strip()
|
||||
if not raw:
|
||||
raise ValueError("path_required")
|
||||
posix = PurePosixPath(raw)
|
||||
if posix.is_absolute():
|
||||
raise ValueError("absolute_path_forbidden")
|
||||
if any(part in {"..", ".git"} for part in posix.parts):
|
||||
raise ValueError("unsafe_path_forbidden")
|
||||
return str(posix)
|
||||
|
||||
|
||||
def is_main_branch(branch: str | None) -> bool:
|
||||
value = (branch or "").strip()
|
||||
return value in {"main", "master"} or value.startswith("main/") or value.startswith("master/")
|
||||
|
||||
|
||||
def is_secret_path(path: str) -> bool:
|
||||
normalized = normalize_path(path)
|
||||
name = PurePosixPath(normalized).name
|
||||
if name.endswith(".token"):
|
||||
return True
|
||||
return any(normalized == pattern.rstrip("/") or normalized.startswith(pattern) for pattern in SECRET_PATH_PATTERNS)
|
||||
|
||||
|
||||
def contains_secret_like_content(content: str) -> bool:
|
||||
text = content or ""
|
||||
return any(pattern.search(text) for pattern in SECRET_CONTENT_PATTERNS)
|
||||
|
||||
|
||||
def path_in_scope(operation_id: str, path: str) -> bool:
|
||||
normalized = normalize_path(path)
|
||||
scopes = LOW_RISK_PATH_SCOPES.get(operation_id, ())
|
||||
return any(normalized.startswith(scope) for scope in scopes)
|
||||
|
||||
|
||||
def validate_low_risk_commit(operation_id: str, branch: str, path: str, content: str = "") -> PolicyResult:
|
||||
if operation_id not in LOW_RISK_PATH_SCOPES:
|
||||
return PolicyResult(False, "unknown_low_risk_operation")
|
||||
if is_main_branch(branch):
|
||||
return PolicyResult(False, "low_risk_action_rejects_main")
|
||||
try:
|
||||
normalized = normalize_path(path)
|
||||
except ValueError as exc:
|
||||
return PolicyResult(False, str(exc))
|
||||
if is_secret_path(normalized):
|
||||
return PolicyResult(False, "secret_path_rejected")
|
||||
if not path_in_scope(operation_id, normalized):
|
||||
return PolicyResult(False, "path_out_of_scope")
|
||||
if contains_secret_like_content(content):
|
||||
return PolicyResult(False, "secret_like_content_detected")
|
||||
return PolicyResult(True, "allowed")
|
||||
|
||||
|
||||
def validate_write_policy(operation_id: str, branch: str, path: str, content: str = "") -> PolicyResult:
|
||||
try:
|
||||
normalized = normalize_path(path)
|
||||
except ValueError as exc:
|
||||
return PolicyResult(False, str(exc))
|
||||
if is_secret_path(normalized):
|
||||
return PolicyResult(False, "secret_path_rejected")
|
||||
if contains_secret_like_content(content):
|
||||
return PolicyResult(False, "secret_like_content_detected")
|
||||
if operation_id in LOW_RISK_PATH_SCOPES:
|
||||
return validate_low_risk_commit(operation_id, branch, normalized, content)
|
||||
if operation_id in HIGH_RISK_OPERATIONS:
|
||||
return PolicyResult(True, "high_risk_requires_confirmation")
|
||||
return PolicyResult(False, "unknown_operation")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("GPT Actions GitOps policy helper loaded")
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCHEMA = ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml"
|
||||
|
||||
LOW_RISK_FALSE = {
|
||||
"commitReportFile",
|
||||
"commitDocsFile",
|
||||
"commitProposalFile",
|
||||
"commitPatchEvidence",
|
||||
}
|
||||
|
||||
HIGH_RISK_TRUE = {
|
||||
"commitFile",
|
||||
"commitMainFile",
|
||||
"createRollbackRequest",
|
||||
"requestRollback",
|
||||
"requestOrCreateTag",
|
||||
"setModelPriority",
|
||||
"registerModel",
|
||||
}
|
||||
|
||||
SECRET_LIKE = re.compile(
|
||||
r"sk-[A-Za-z0-9_-]{8,}|Bearer\s+\S+|BEGIN\s+PRIVATE\s+KEY|password\s*=|api_key\s*=|access_token\s*=|refresh_token\s*=",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def load_schema() -> dict:
|
||||
if not SCHEMA.exists():
|
||||
raise AssertionError(f"schema missing: {SCHEMA}")
|
||||
return yaml.safe_load(SCHEMA.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def operations(schema: dict) -> dict[str, dict]:
|
||||
found: dict[str, dict] = {}
|
||||
for path_item in schema.get("paths", {}).values():
|
||||
if not isinstance(path_item, dict):
|
||||
continue
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "patch", "delete"}:
|
||||
continue
|
||||
if isinstance(operation, dict) and operation.get("operationId"):
|
||||
found[operation["operationId"]] = operation
|
||||
return found
|
||||
|
||||
|
||||
def collect_examples(value):
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if key in {"example", "examples"}:
|
||||
yield item
|
||||
yield from collect_examples(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
yield from collect_examples(item)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
schema = load_schema()
|
||||
ops = operations(schema)
|
||||
failures: list[str] = []
|
||||
|
||||
for op_id in sorted(LOW_RISK_FALSE):
|
||||
operation = ops.get(op_id)
|
||||
if not operation:
|
||||
failures.append(f"missing low-risk operation: {op_id}")
|
||||
elif operation.get("x-openai-isConsequential") is not False:
|
||||
failures.append(f"{op_id} must set x-openai-isConsequential=false")
|
||||
|
||||
for op_id in sorted(HIGH_RISK_TRUE):
|
||||
operation = ops.get(op_id)
|
||||
if not operation:
|
||||
failures.append(f"missing high-risk operation: {op_id}")
|
||||
elif operation.get("x-openai-isConsequential") is not True:
|
||||
failures.append(f"{op_id} must set x-openai-isConsequential=true")
|
||||
|
||||
security_text = json.dumps(schema.get("components", {}).get("securitySchemes", {}), ensure_ascii=False)
|
||||
if SECRET_LIKE.search(security_text):
|
||||
failures.append("securitySchemes contain secret-like content")
|
||||
|
||||
for example in collect_examples(schema):
|
||||
if SECRET_LIKE.search(json.dumps(example, ensure_ascii=False)):
|
||||
failures.append("schema examples contain secret-like content")
|
||||
break
|
||||
|
||||
print("GPT Actions consequential governance smoke")
|
||||
print("schema:", SCHEMA)
|
||||
print("low-risk:", ", ".join(sorted(LOW_RISK_FALSE)))
|
||||
print("high-risk:", ", ".join(sorted(HIGH_RISK_TRUE)))
|
||||
if failures:
|
||||
print("status: failed")
|
||||
for failure in failures:
|
||||
print("-", failure)
|
||||
return 1
|
||||
print("status: passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app" / "aios_chatgpt_actions_api.py"
|
||||
|
||||
|
||||
def module_tree():
|
||||
return ast.parse(APP.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_runtime_defines_low_risk_route_map():
|
||||
text = APP.read_text(encoding="utf-8")
|
||||
for route in [
|
||||
"/api/gitops/report-file",
|
||||
"/api/gitops/docs-file",
|
||||
"/api/gitops/proposal-file",
|
||||
"/api/gitops/patch-evidence",
|
||||
"/api/gitops/main-file",
|
||||
"/api/gitops/rollback-request",
|
||||
]:
|
||||
assert route in text
|
||||
|
||||
|
||||
def test_runtime_has_low_risk_and_main_handlers():
|
||||
names = {node.name for node in ast.walk(module_tree()) if isinstance(node, ast.FunctionDef)}
|
||||
assert "low_risk_file" in names
|
||||
assert "main_file" in names
|
||||
assert "secret_path" in names
|
||||
assert "path_in_scope" in names
|
||||
|
||||
|
||||
def test_runtime_policy_rejects_main_and_secret_paths_in_low_risk_handler():
|
||||
text = APP.read_text(encoding="utf-8")
|
||||
assert "low-risk write requires explicit non-main patch/request branch" in text
|
||||
assert "Secret-like path rejected. Use runtime secret store or secret_ref." in text
|
||||
assert "path_out_of_scope" in text
|
||||
assert "Secret-like content detected. Use runtime secret store or secret_ref." in text
|
||||
|
||||
|
||||
def test_runtime_preserves_legacy_commit_file_route():
|
||||
text = APP.read_text(encoding="utf-8")
|
||||
assert "if path == '/api/file': return self.file(data)" in text
|
||||
assert "def file(self,data):" in text
|
||||
@@ -0,0 +1,70 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from gitops_actions_policy import validate_low_risk_commit, validate_write_policy # noqa: E402
|
||||
|
||||
|
||||
def assert_allowed(operation_id, branch, path, content="safe content"):
|
||||
result = validate_low_risk_commit(operation_id, branch, path, content)
|
||||
assert result.allowed, result.reason
|
||||
|
||||
|
||||
def assert_rejected(operation_id, branch, path, reason, content="safe content"):
|
||||
result = validate_low_risk_commit(operation_id, branch, path, content)
|
||||
assert not result.allowed
|
||||
assert result.reason == reason
|
||||
|
||||
|
||||
def test_commit_report_file_scope():
|
||||
assert_allowed("commitReportFile", "patch/gpt-actions-consequential-governance", "reports/example.md")
|
||||
assert_rejected("commitReportFile", "patch/gpt-actions-consequential-governance", "docs/example.md", "path_out_of_scope")
|
||||
|
||||
|
||||
def test_commit_docs_file_scope():
|
||||
assert_allowed("commitDocsFile", "patch/gpt-actions-consequential-governance", "docs/architecture/example.md")
|
||||
assert_rejected("commitDocsFile", "patch/gpt-actions-consequential-governance", "reports/example.md", "path_out_of_scope")
|
||||
|
||||
|
||||
def test_commit_proposal_and_patch_evidence_scopes():
|
||||
assert_allowed("commitProposalFile", "patch/gpt-actions-consequential-governance", "proposals/change.json")
|
||||
assert_allowed("commitPatchEvidence", "patch/gpt-actions-consequential-governance", "reports/evidence.md")
|
||||
assert_allowed("commitPatchEvidence", "patch/gpt-actions-consequential-governance", "patches/evidence.json")
|
||||
assert_rejected("commitPatchEvidence", "patch/gpt-actions-consequential-governance", "docs/evidence.md", "path_out_of_scope")
|
||||
|
||||
|
||||
def test_low_risk_actions_reject_main_branch():
|
||||
for operation_id in ["commitReportFile", "commitDocsFile", "commitProposalFile", "commitPatchEvidence"]:
|
||||
assert_rejected(operation_id, "main", "reports/example.md", "low_risk_action_rejects_main")
|
||||
|
||||
|
||||
def test_all_write_actions_reject_secret_paths():
|
||||
secret_paths = ["runtime/secrets/key.txt", ".env", "private/key.txt", "prod-secrets/config.json", "reports/value.token"]
|
||||
for operation_id in ["commitReportFile", "commitDocsFile", "commitPatchEvidence", "commitFile", "commitMainFile", "createRollbackRequest"]:
|
||||
for path in secret_paths:
|
||||
result = validate_write_policy(operation_id, "patch/gpt-actions-consequential-governance", path)
|
||||
assert not result.allowed
|
||||
assert result.reason == "secret_path_rejected"
|
||||
|
||||
|
||||
def test_secret_like_content_is_rejected():
|
||||
samples = [
|
||||
"Bearer abc",
|
||||
"password=abc",
|
||||
"api_key=abc",
|
||||
"BEGIN PRIVATE KEY",
|
||||
"access_token=abc",
|
||||
"refresh_token=abc",
|
||||
]
|
||||
for sample in samples:
|
||||
result = validate_low_risk_commit(
|
||||
"commitReportFile",
|
||||
"patch/gpt-actions-consequential-governance",
|
||||
"reports/example.md",
|
||||
sample,
|
||||
)
|
||||
assert not result.allowed
|
||||
assert result.reason == "secret_like_content_detected"
|
||||
@@ -0,0 +1,76 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCHEMA = ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml"
|
||||
|
||||
|
||||
def load_schema():
|
||||
return yaml.safe_load(SCHEMA.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def operations_by_id(schema):
|
||||
operations = {}
|
||||
for path_item in schema.get("paths", {}).values():
|
||||
if not isinstance(path_item, dict):
|
||||
continue
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in {"get", "post", "put", "patch", "delete"}:
|
||||
continue
|
||||
if isinstance(operation, dict) and operation.get("operationId"):
|
||||
operations[operation["operationId"]] = operation
|
||||
return operations
|
||||
|
||||
|
||||
def _examples(value):
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if key in {"example", "examples"}:
|
||||
yield item
|
||||
yield from _examples(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
yield from _examples(item)
|
||||
|
||||
|
||||
def test_low_risk_operations_are_non_consequential():
|
||||
ops = operations_by_id(load_schema())
|
||||
for op_id in ["commitReportFile", "commitDocsFile", "commitProposalFile", "commitPatchEvidence"]:
|
||||
assert op_id in ops
|
||||
assert ops[op_id].get("x-openai-isConsequential") is False
|
||||
|
||||
|
||||
def test_high_risk_operations_remain_consequential():
|
||||
ops = operations_by_id(load_schema())
|
||||
for op_id in [
|
||||
"commitFile",
|
||||
"commitMainFile",
|
||||
"createRollbackRequest",
|
||||
"requestRollback",
|
||||
"requestOrCreateTag",
|
||||
"setModelPriority",
|
||||
"registerModel",
|
||||
]:
|
||||
assert op_id in ops
|
||||
assert ops[op_id].get("x-openai-isConsequential") is True
|
||||
|
||||
|
||||
def test_legacy_commit_file_is_preserved_as_consequential():
|
||||
ops = operations_by_id(load_schema())
|
||||
assert ops["commitFile"].get("x-openai-isConsequential") is True
|
||||
|
||||
|
||||
def test_security_schemes_and_examples_do_not_embed_real_credentials():
|
||||
schema = load_schema()
|
||||
secret_like = re.compile(
|
||||
r"sk-[A-Za-z0-9_-]{8,}|BEGIN\s+PRIVATE\s+KEY|password\s*=|api_key\s*=|access_token\s*=|refresh_token\s*=",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
checked = json.dumps(schema.get("components", {}).get("securitySchemes", {}), ensure_ascii=False)
|
||||
for value in _examples(schema):
|
||||
checked += "\n" + json.dumps(value, ensure_ascii=False)
|
||||
assert not secret_like.search(checked)
|
||||
Reference in New Issue
Block a user