trustdex 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,9 +54,9 @@ ALLOW 결과는 여전히 관찰된 증거에 대한 정책 결과일 뿐입니
54
54
 
55
55
  `inspect-claude-settings`는 문서화된 `permissions` 객체를 검사하고 셸, 파일 시스템, 네트워크, MCP 및 서브에이전트 기능의 자동 승인을 보고합니다. 또한 훅, 자격 증명 도우미, 환경 설정, 샌드박스 설정 및 플러그인 로딩의 존재를 값은 보관하지 않은 채 드러냅니다.
56
56
 
57
- 이는 정적 증거이지 런타임 강제 적용이 아닙니다. 모든 Claude 설정 범위를 병합하거나, 명령줄 플래그를 검사하거나, 관리형 설정이 세션에 실제 적용되었음을 증명하거나, Claude Code 샌드박싱을 대체하지 않습니다. 운영자는 실제 런타임 설정을 별도로 확인해야 합니다.
57
+ `inspect-claude-settings-stack`은 명시적으로 전달된 사용자, 프로젝트, 로컬, 세션 및 관리형 소스에 문서화된 우선순위를 적용합니다. 이는 정적 증거이지 런타임 강제 적용이 아닙니다. 소스를 자동으로 찾거나 관리형 설정이 세션에 실제 적용되었음을 증명하지 않으며 Claude Code 샌드박싱을 대체하지 않습니다. 운영자는 실제 런타임 설정을 별도로 확인해야 합니다.
58
58
 
59
- `inspect-codex-settings`는 Codex 승인 정책, 검토자 선택, 샌드박스 종류, 네트워크 접근, 추가 쓰기 가능 루트 개수, 웹 검색 모드 및 명시적인 서브에이전트 활성화를 평가합니다. 완전히 파싱하지 않는 사용자 정의 권한, 앱 및 플러그인 표면은 값은 보관하지 않고 보고합니다. 마찬가지로 모든 Codex 설정 계층을 병합하거나 활성 Codex 샌드박스를 대체하지 않습니다.
59
+ `inspect-codex-settings`는 Codex 승인 정책, 검토자 선택, 샌드박스 종류, 네트워크 접근, 추가 쓰기 가능 루트 개수, 웹 검색 모드 및 명시적인 서브에이전트 활성화를 평가합니다. 완전히 파싱하지 않는 사용자 정의 권한, 앱 및 플러그인 표면은 값은 보관하지 않고 보고합니다. `inspect-codex-settings-stack`은 전달된 파일에 시스템부터 CLI까지의 문서화된 우선순위와 순서가 있는 프로젝트 계층을 적용합니다. 활성 계층을 자동으로 찾거나 요구사항을 강제하거나 활성 Codex 샌드박스를 대체하지 않습니다.
60
60
 
61
61
  ## CVP 신청 경계
62
62
 
@@ -54,13 +54,15 @@ An ALLOW result is still only a policy result over observed evidence. TrustDex i
54
54
 
55
55
  `inspect-claude-settings` examines the documented `permissions` object and reports automatic approval of shell, filesystem, network, MCP, and subagent capabilities. It also surfaces the presence of hooks, credential helpers, environment settings, sandbox configuration, and plugin loading without retaining their values.
56
56
 
57
- This is static evidence, not runtime enforcement. It does not merge every Claude settings scope, inspect command-line flags, prove that managed settings reached a session, or replace Claude Code sandboxing. The operator must confirm the effective runtime configuration separately.
57
+ `inspect-claude-settings-stack` applies documented precedence to explicitly supplied user, project, local, session, and managed sources. This is static evidence, not runtime enforcement. It does not discover sources or prove that managed settings reached a session, and it does not replace Claude Code sandboxing. The operator must confirm the effective runtime configuration separately.
58
58
 
59
59
  `inspect-codex-settings` evaluates Codex approval policy, reviewer choice, sandbox
60
60
  class, network access, additional writable-root count, web-search mode, and explicit
61
61
  subagent enablement. It reports custom permission, app, and plugin surfaces that it
62
- does not fully parse without retaining their values. It likewise does not merge every
63
- Codex configuration layer or replace the active Codex sandbox.
62
+ does not fully parse without retaining their values. `inspect-codex-settings-stack`
63
+ applies documented system-through-CLI precedence to supplied files, including ordered
64
+ project layers. It does not discover the active layers, enforce requirements, or replace
65
+ the active Codex sandbox.
64
66
 
65
67
  ## CVP application boundary
66
68
 
@@ -77,8 +77,19 @@
77
77
  제거된 권한 규칙 안에서 개수는 같고 내용만 바뀌는 경우는 이 구조적 비교의
78
78
  범위 밖입니다.
79
79
 
80
+ ## 병합 설정 스택 사례
81
+
82
+ `test/settings-stack.test.mjs`는 두 런타임의 명시적 소스 스택을 검사합니다. 다음을 요구합니다.
83
+
84
+ - Claude 권한 목록을 결합하면서 관리형/세션/로컬/프로젝트/사용자 스칼라 우선순위를 유지
85
+ - 프로젝트 또는 로컬 Claude 소스가 `auto`나 `bypassPermissions` 기본 모드를 활성화하지 않음
86
+ - Codex 시스템/관리형/사용자/프로필/프로젝트/CLI 우선순위와 가장 가까운 프로젝트 순서 적용
87
+ - 높은 소스가 설정을 대체하더라도 잘못된 낮은 우선순위 입력은 실패
88
+ - 스키마 v2 스냅샷은 고정된 범위 개수만 보관하고 소스 구성 변경에 재검토 요구
89
+ - 소스 경로, 규칙, 도메인, 명령, 쓰기 가능 경로, 환경 변수 이름과 값을 계속 제외
90
+
80
91
  ## 이 평가가 측정하지 않는 것
81
92
 
82
- 이 벤치마크는 의도적으로 범위가 좁습니다. 런타임 동작, 샌드박스 탈출, 임의의 악성 코드, 모든 형태의 프롬프트 인젝션, 게시자 신원 소유권 또는 전이 의존성의 취약점을 측정하지 않습니다. 모든 Claude 또는 Codex 설정 범위를 병합하거나 세션 명령줄 재정의를 검사하거나 보고된 샌드박스가 실제로 활성화되었음을 증명하지 않습니다. 특히 이미 실행 중인 MCP 서버가 동적으로 반환하는 도구 설명은 현재 정적 설정 경계 밖에 있습니다.
93
+ 이 벤치마크는 의도적으로 범위가 좁습니다. 런타임 동작, 샌드박스 탈출, 임의의 악성 코드, 모든 형태의 프롬프트 인젝션, 게시자 신원 소유권 또는 전이 의존성의 취약점을 측정하지 않습니다. 스택 사례는 테스트에 명시적으로 전달된 소스만 병합하며 활성 설정, 요구사항, 환경 변수 재정의 또는 서버 관리 정책을 자동으로 찾지 않고 보고된 샌드박스가 실제로 활성화되었음도 증명하지 않습니다. 특히 이미 실행 중인 MCP 서버가 동적으로 반환하는 도구 설명은 현재 정적 설정 경계 밖에 있습니다.
83
94
 
84
95
  이 작은 픽스처 집합은 회귀 기준선이지 탐지 정확도에 대한 통계적 주장이 아닙니다. 거짓 양성과 거짓 음성 비율을 측정하려면 민감 정보를 제거한 실제 설정으로 구성되고 독립적으로 검토된 더 큰 코퍼스가 필요합니다.
@@ -79,8 +79,19 @@ for both runtimes. `test/posture.test.mjs` requires:
79
79
  The digest checks internal consistency, not signer identity. Same-count changes inside
80
80
  redacted permission rules remain outside this structural comparison.
81
81
 
82
+ ## Merged settings stack cases
83
+
84
+ `test/settings-stack.test.mjs` exercises explicit source stacks for both runtimes. It requires:
85
+
86
+ - Claude permission lists to combine while managed/session/local/project/user scalar precedence is preserved
87
+ - project or local Claude sources not to activate `auto` or `bypassPermissions` default modes
88
+ - Codex system/managed/user/profile/project/CLI precedence and closest-project ordering
89
+ - invalid lower-precedence input to fail even when a higher source would replace the setting
90
+ - schema-v2 snapshots to retain only fixed scope counts and to require review when source topology changes
91
+ - source paths, rules, domains, commands, writable paths, environment names, and values to remain absent
92
+
82
93
  ## What this does not measure
83
94
 
84
- This benchmark is intentionally narrow. It does not measure runtime behavior, sandbox escape, arbitrary malware, every form of prompt injection, publisher identity ownership, or vulnerabilities in transitive dependencies. It does not merge every Claude or Codex settings scope, inspect session command-line overrides, or prove that the reported sandbox is active. In particular, tool descriptions returned dynamically by an already-running MCP server are outside the current static configuration boundary.
95
+ This benchmark is intentionally narrow. It does not measure runtime behavior, sandbox escape, arbitrary malware, every form of prompt injection, publisher identity ownership, or vulnerabilities in transitive dependencies. The stack cases merge only sources explicitly supplied to the test; they do not discover active settings, requirements, environment overrides, or server-managed policy, and they do not prove that the reported sandbox is active. In particular, tool descriptions returned dynamically by an already-running MCP server are outside the current static configuration boundary.
85
96
 
86
97
  The small fixture set is a regression baseline, not a statistical claim about detection accuracy. False-positive and false-negative rates require a larger, independently reviewed corpus of redacted real-world configurations.
@@ -12,6 +12,8 @@ TrustDex는 초기 단계입니다. 이 로드맵은 방향을 공유하며 릴
12
12
  - TrustDex 0.7.0은 값이 제거되고 자체 일관성을 검증하는 권한 상태 스냅샷,
13
13
  자율성 확대 검토 게이트, 버전 지정 JSON 스키마 및 공개 적대적 권한 픽스처를
14
14
  추가합니다.
15
+ - TrustDex 0.8.0은 문서화된 Claude Code 및 Codex 우선순위를 위한 명시적 병합 소스
16
+ 검사, 스키마 v2 소스 구성 증거 및 안전하게 실패하는 스택 테스트를 추가합니다.
15
17
 
16
18
  ## 현재: 경계 강화
17
19
 
@@ -24,11 +26,10 @@ TrustDex는 초기 단계입니다. 이 로드맵은 방향을 공유하며 릴
24
26
 
25
27
  ## 다음: 에이전트 행동 경계를 검토 가능하게 만들기
26
28
 
27
- - 명령, 경로, 도메인 또는 자격 증명 값을 보관하지 않고 병합된 설정 소스 검사
28
- - 초기 단일 파일 권한 상태 비교를 병합 설정 소스로 확장
29
29
  - 승인된 도구 출처와 해당 도구에 부여된 행동 권한 연결
30
30
  - 버전이 지정된 권한 상태 JSON 스키마를 추가 결정 및 런타임으로 확장
31
31
  - 권고형 설정 검사가 런타임 및 운영체제 샌드박스와 결합되는 방식 문서화
32
+ - 비공개 파일을 조용히 찾지 않으면서 런타임이 생성한 실제 설정 증거 추가
32
33
 
33
34
  ## 그다음: 무결성 증명
34
35
 
package/docs/ROADMAP.md CHANGED
@@ -12,6 +12,8 @@ TrustDex is early-stage. This roadmap communicates direction and is not a releas
12
12
  - TrustDex 0.7.0 adds value-free, self-validating permission posture snapshots,
13
13
  autonomy-expansion review gates, versioned JSON schemas, and public adversarial
14
14
  permission fixtures.
15
+ - TrustDex 0.8.0 adds explicit merged-source inspection for documented Claude Code
16
+ and Codex precedence, schema-v2 source topology evidence, and fail-closed stack tests.
15
17
 
16
18
  ## Now: harden the boundary
17
19
 
@@ -24,11 +26,10 @@ TrustDex is early-stage. This roadmap communicates direction and is not a releas
24
26
 
25
27
  ## Next: make agent action boundaries reviewable
26
28
 
27
- - inspect merged settings sources without retaining commands, paths, domains, or credential values
28
- - extend the initial single-file posture comparison to merged settings sources
29
29
  - connect approved tool provenance to the action permissions granted to that tool
30
30
  - extend the versioned permission-posture JSON schemas to additional decisions and runtimes
31
31
  - document how advisory settings checks compose with runtime and operating-system sandboxes
32
+ - add runtime-produced effective-setting evidence without silently discovering private files
32
33
 
33
34
  ## Then: prove integrity
34
35
 
@@ -23,6 +23,7 @@ TrustDex는 하나의 좁은 질문을 다룹니다.
23
23
  - 샌드박스를 제거하거나 승인을 생략하거나 네트워크 접근 또는 쓰기 가능 루트를 추가하는 Codex 설정
24
24
  - 명시적인 검토 없이 변경되는 행동 권한 설정
25
25
  - 값이 제거된 구조적 기준 상태보다 확대되는 Claude 또는 Codex 권한 상태
26
+ - 명시적으로 전달된 Claude 또는 Codex 설정 소스 사이의 우선순위 변경
26
27
 
27
28
  ## 신뢰 경계
28
29
 
@@ -45,7 +46,8 @@ TrustDex는 익숙한 프로젝트 이름, 스타 수, 패키지 이름 또는
45
46
  - 로컬 신뢰 저장소 주장만으로 게시자 소유권을 독립적으로 증명
46
47
  - 완전히 침해된 호스트 보호
47
48
  - 도난당한 서명 비공개 키로부터 보호
48
- - 모든 사용자, 프로젝트, 프로필, 관리형, 시스템, 환경 및 명령줄 소스를 병합한 실제 설정 계산
49
+ - 모든 활성 설정 소스, 프로필, 환경 변수 재정의, 요구사항 또는 서버 전달 정책의 자동 탐색
50
+ - 명시적으로 병합한 설정 투영이 런타임에서 활성화된 정책임을 증명하는 것
49
51
  - Claude Code 또는 Codex 행동 권한의 런타임 강제 적용
50
52
  - 서명되지 않은 권한 상태 스냅샷 작성자의 인증
51
53
 
@@ -23,6 +23,7 @@ TrustDex addresses one narrow question:
23
23
  - Codex configurations that remove the sandbox, suppress approvals, enable network access, or add writable roots
24
24
  - action-permission settings changing without an explicit review
25
25
  - Claude or Codex permission posture expanding relative to a value-free structural baseline
26
+ - precedence changes across explicitly supplied Claude or Codex settings sources
26
27
 
27
28
  ## Trust boundaries
28
29
 
@@ -45,7 +46,8 @@ TrustDex does **not** treat a familiar project name, star count, package name, o
45
46
  - independently proving publisher ownership from a local trust-store claim
46
47
  - protecting a fully compromised host
47
48
  - protecting a stolen signing private key
48
- - calculating the effective configuration after every user, project, profile, managed, system, environment, and command-line source is merged
49
+ - automatically discovering every active settings source, profile, environment override, requirement, or server-delivered policy
50
+ - proving that the explicitly merged settings projection is the policy active at runtime
49
51
  - enforcing Claude Code or Codex action permissions at runtime
50
52
  - authenticating the author of an unsigned permission posture snapshot
51
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustdex",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A local-first trust, provenance, and review gate for AI agent tools, MCP servers, skills, and plugins.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test",
11
- "test:research": "node --test test/research-fixtures.test.mjs test/claude-settings.test.mjs test/codex-settings.test.mjs test/posture.test.mjs",
11
+ "test:research": "node --test test/research-fixtures.test.mjs test/claude-settings.test.mjs test/codex-settings.test.mjs test/posture.test.mjs test/settings-stack.test.mjs",
12
12
  "check": "node --check bin/trustdex.mjs && node --check src/canonical.mjs && node --check src/claude-config.mjs && node --check src/claude-settings.mjs && node --check src/codex-config.mjs && node --check src/codex-settings.mjs && node --check src/extensions.mjs && node --check src/gate.mjs && node --check src/inspect.mjs && node --check src/policy.mjs && node --check src/policy-packs.mjs && node --check src/posture.mjs && node --check src/provenance.mjs && node --check src/provenance-online.mjs && node --check src/review.mjs && node --check src/snapshot.mjs && node --check src/trust-record.mjs",
13
13
  "prepublishOnly": "npm test && npm run check"
14
14
  },
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/grigent/trustdex/blob/main/schemas/permission-posture-diff-v2.schema.json",
4
+ "title": "TrustDex merged permission posture diff v2",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schemaVersion",
9
+ "runtime",
10
+ "status",
11
+ "baselineDigest",
12
+ "currentDigest",
13
+ "summary",
14
+ "changes"
15
+ ],
16
+ "properties": {
17
+ "schemaVersion": { "const": 2 },
18
+ "runtime": { "enum": ["claude", "codex"] },
19
+ "status": { "enum": ["unchanged", "restricted", "needs-review"] },
20
+ "baselineDigest": { "$ref": "permission-posture-diff-v1.schema.json#/$defs/digest" },
21
+ "currentDigest": { "$ref": "permission-posture-diff-v1.schema.json#/$defs/digest" },
22
+ "summary": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["expansions", "restrictions", "reviews"],
26
+ "properties": {
27
+ "expansions": { "$ref": "permission-posture-diff-v1.schema.json#/$defs/count" },
28
+ "restrictions": { "$ref": "permission-posture-diff-v1.schema.json#/$defs/count" },
29
+ "reviews": { "$ref": "permission-posture-diff-v1.schema.json#/$defs/count" }
30
+ }
31
+ },
32
+ "changes": {
33
+ "type": "array",
34
+ "items": { "$ref": "permission-posture-diff-v1.schema.json#/$defs/change" }
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,117 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/grigent/trustdex/blob/main/schemas/permission-posture-v2.schema.json",
4
+ "title": "TrustDex merged permission posture snapshot v2",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schemaVersion",
9
+ "kind",
10
+ "createdAt",
11
+ "runtime",
12
+ "policyDigest",
13
+ "posture",
14
+ "sources",
15
+ "digest"
16
+ ],
17
+ "properties": {
18
+ "schemaVersion": { "const": 2 },
19
+ "kind": { "const": "agent-permission-posture" },
20
+ "createdAt": { "type": "string", "format": "date-time" },
21
+ "runtime": { "enum": ["claude", "codex"] },
22
+ "policyDigest": { "$ref": "permission-posture-v1.schema.json#/$defs/digest" },
23
+ "digest": { "$ref": "permission-posture-v1.schema.json#/$defs/digest" },
24
+ "posture": {
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "required": ["action", "signals", "metadata"],
28
+ "properties": {
29
+ "action": { "enum": ["allow", "ask", "block"] },
30
+ "signals": {
31
+ "type": "array",
32
+ "uniqueItems": true,
33
+ "items": { "type": "string" }
34
+ },
35
+ "metadata": { "type": "object" }
36
+ }
37
+ },
38
+ "sources": { "type": "object" }
39
+ },
40
+ "allOf": [
41
+ {
42
+ "if": { "properties": { "runtime": { "const": "claude" } } },
43
+ "then": {
44
+ "properties": {
45
+ "posture": {
46
+ "properties": {
47
+ "metadata": { "$ref": "permission-posture-v1.schema.json#/$defs/claudeMetadata" }
48
+ }
49
+ },
50
+ "sources": { "$ref": "#/$defs/claudeSources" }
51
+ }
52
+ }
53
+ },
54
+ {
55
+ "if": { "properties": { "runtime": { "const": "codex" } } },
56
+ "then": {
57
+ "properties": {
58
+ "posture": {
59
+ "properties": {
60
+ "metadata": { "$ref": "permission-posture-v1.schema.json#/$defs/codexMetadata" }
61
+ }
62
+ },
63
+ "sources": { "$ref": "#/$defs/codexSources" }
64
+ }
65
+ }
66
+ }
67
+ ],
68
+ "$defs": {
69
+ "count": {
70
+ "type": "integer",
71
+ "minimum": 0
72
+ },
73
+ "claudeSources": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "required": ["sourceCount", "activeSourceCount", "sourceCounts"],
77
+ "properties": {
78
+ "sourceCount": { "$ref": "#/$defs/count" },
79
+ "activeSourceCount": { "$ref": "#/$defs/count" },
80
+ "sourceCounts": {
81
+ "type": "object",
82
+ "additionalProperties": false,
83
+ "required": ["user", "project", "local", "session", "managed"],
84
+ "properties": {
85
+ "user": { "$ref": "#/$defs/count" },
86
+ "project": { "$ref": "#/$defs/count" },
87
+ "local": { "$ref": "#/$defs/count" },
88
+ "session": { "$ref": "#/$defs/count" },
89
+ "managed": { "$ref": "#/$defs/count" }
90
+ }
91
+ }
92
+ }
93
+ },
94
+ "codexSources": {
95
+ "type": "object",
96
+ "additionalProperties": false,
97
+ "required": ["sourceCount", "activeSourceCount", "sourceCounts"],
98
+ "properties": {
99
+ "sourceCount": { "$ref": "#/$defs/count" },
100
+ "activeSourceCount": { "$ref": "#/$defs/count" },
101
+ "sourceCounts": {
102
+ "type": "object",
103
+ "additionalProperties": false,
104
+ "required": ["system", "managed", "user", "profile", "project", "cli"],
105
+ "properties": {
106
+ "system": { "$ref": "#/$defs/count" },
107
+ "managed": { "$ref": "#/$defs/count" },
108
+ "user": { "$ref": "#/$defs/count" },
109
+ "profile": { "$ref": "#/$defs/count" },
110
+ "project": { "$ref": "#/$defs/count" },
111
+ "cli": { "$ref": "#/$defs/count" }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
@@ -19,6 +19,14 @@ const MODES = new Set([
19
19
  'auto'
20
20
  ]);
21
21
  const NON_PERMISSION_ROOT_KEYS = new Set(['$schema', 'permissions']);
22
+ const CLAUDE_SCOPE_RANK = Object.freeze({
23
+ user: 0,
24
+ project: 1,
25
+ local: 2,
26
+ session: 3,
27
+ managed: 4
28
+ });
29
+ const MERGED_PERMISSION_LISTS = new Set(['allow', 'ask', 'deny', 'additionalDirectories']);
22
30
 
23
31
  function isObject(value) {
24
32
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -171,3 +179,76 @@ export function inspectClaudeSettings(settings, sourceName = 'settings.json') {
171
179
  envKeys
172
180
  };
173
181
  }
182
+
183
+ function normalizeClaudeLayers(layers) {
184
+ if (!Array.isArray(layers) || !layers.length) {
185
+ throw new Error('Claude settings stack requires at least one settings source.');
186
+ }
187
+ const seen = new Set();
188
+ return layers.map((layer, index) => {
189
+ if (!isObject(layer) || !Object.hasOwn(CLAUDE_SCOPE_RANK, layer.scope)) {
190
+ throw new Error('Unsupported Claude settings source scope.');
191
+ }
192
+ if (seen.has(layer.scope)) {
193
+ throw new Error(`Claude settings stack accepts only one ${layer.scope} source.`);
194
+ }
195
+ seen.add(layer.scope);
196
+ if (!isObject(layer.settings)) throw new Error('Claude settings source must contain a JSON object.');
197
+ inspectClaudeSettings(layer.settings, 'Claude settings source');
198
+ return { ...layer, index };
199
+ }).sort((left, right) =>
200
+ CLAUDE_SCOPE_RANK[left.scope] - CLAUDE_SCOPE_RANK[right.scope] || left.index - right.index
201
+ );
202
+ }
203
+
204
+ function mergeClaudeSettings(layers) {
205
+ const merged = {};
206
+ const permissions = {};
207
+
208
+ for (const { scope, settings } of layers) {
209
+ for (const [key, value] of Object.entries(settings)) {
210
+ if (key === 'permissions') continue;
211
+ if (Array.isArray(value) && Array.isArray(merged[key])) merged[key] = [...merged[key], ...value];
212
+ else merged[key] = value;
213
+ }
214
+
215
+ const current = settings.permissions || {};
216
+ for (const [key, value] of Object.entries(current)) {
217
+ if (MERGED_PERMISSION_LISTS.has(key)) {
218
+ permissions[key] = [...(permissions[key] || []), ...value];
219
+ continue;
220
+ }
221
+ if (key === 'defaultMode' && ['project', 'local'].includes(scope) &&
222
+ ['auto', 'bypassPermissions'].includes(value)) {
223
+ continue;
224
+ }
225
+ permissions[key] = value;
226
+ }
227
+ }
228
+
229
+ if (Object.keys(permissions).length) merged.permissions = permissions;
230
+ return merged;
231
+ }
232
+
233
+ export function inspectClaudeSettingsStack(layers) {
234
+ const normalized = normalizeClaudeLayers(layers);
235
+ const merged = mergeClaudeSettings(normalized);
236
+ const result = inspectClaudeSettings(merged, 'merged Claude settings');
237
+ const sourceCounts = Object.fromEntries(Object.keys(CLAUDE_SCOPE_RANK).map((scope) => [scope, 0]));
238
+ let activeSourceCount = 0;
239
+ for (const layer of normalized) {
240
+ sourceCounts[layer.scope] += 1;
241
+ if (Object.keys(layer.settings).length) activeSourceCount += 1;
242
+ }
243
+ return {
244
+ ...result,
245
+ name: 'merged Claude settings',
246
+ kind: 'claude-settings-stack',
247
+ source: { type: 'local', format: 'claude-settings-stack', scopes: sourceCounts },
248
+ settingsContext: {
249
+ sourceCount: normalized.length,
250
+ activeSourceCount,
251
+ sourceCounts
252
+ }
253
+ };
254
+ }
@@ -25,6 +25,14 @@ const INSPECTED_ROOT_KEYS = new Set([
25
25
  'default_permissions',
26
26
  'web_search'
27
27
  ]);
28
+ const CODEX_SCOPE_RANK = Object.freeze({
29
+ system: 0,
30
+ managed: 1,
31
+ user: 2,
32
+ profile: 3,
33
+ project: 4,
34
+ cli: 5
35
+ });
28
36
 
29
37
  function isObject(value) {
30
38
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -154,8 +162,7 @@ function validateSandbox(sandbox) {
154
162
  }
155
163
  }
156
164
 
157
- export function inspectCodexSettingsText(text, sourceName = 'config.toml') {
158
- const { root, sandbox, agents, sections } = parseTomlAssignments(text);
165
+ function inspectParsedCodexSettings({ root, sandbox, agents, sections }, sourceName, kind) {
159
166
  validateSandbox(sandbox);
160
167
 
161
168
  const signals = new Set();
@@ -222,7 +229,7 @@ export function inspectCodexSettingsText(text, sourceName = 'config.toml') {
222
229
 
223
230
  return {
224
231
  name: sourceName,
225
- kind: 'codex-settings',
232
+ kind,
226
233
  source: { type: 'local', format: 'codex-settings', path: sourceName },
227
234
  metadata: {
228
235
  approvalPolicy: approval.mode,
@@ -245,3 +252,74 @@ export function inspectCodexSettingsText(text, sourceName = 'config.toml') {
245
252
  envKeys: []
246
253
  };
247
254
  }
255
+
256
+ export function inspectCodexSettingsText(text, sourceName = 'config.toml') {
257
+ return inspectParsedCodexSettings(
258
+ parseTomlAssignments(text),
259
+ sourceName,
260
+ 'codex-settings'
261
+ );
262
+ }
263
+
264
+ function normalizeCodexLayers(layers) {
265
+ if (!Array.isArray(layers) || !layers.length) {
266
+ throw new Error('Codex settings stack requires at least one config source.');
267
+ }
268
+ const seen = new Set();
269
+ return layers.map((layer, index) => {
270
+ if (!isObject(layer) || !Object.hasOwn(CODEX_SCOPE_RANK, layer.scope)) {
271
+ throw new Error('Unsupported Codex config source scope.');
272
+ }
273
+ if (layer.scope !== 'project' && seen.has(layer.scope)) {
274
+ throw new Error(`Codex settings stack accepts only one ${layer.scope} source.`);
275
+ }
276
+ seen.add(layer.scope);
277
+ if (typeof layer.text !== 'string') throw new Error('Codex config source must contain TOML text.');
278
+ const parsed = parseTomlAssignments(layer.text);
279
+ inspectParsedCodexSettings(parsed, 'Codex settings source', 'codex-settings');
280
+ return { ...layer, parsed, index };
281
+ }).sort((left, right) =>
282
+ CODEX_SCOPE_RANK[left.scope] - CODEX_SCOPE_RANK[right.scope] || left.index - right.index
283
+ );
284
+ }
285
+
286
+ export function inspectCodexSettingsStack(layers) {
287
+ const normalized = normalizeCodexLayers(layers);
288
+ const merged = {
289
+ root: Object.create(null),
290
+ sandbox: Object.create(null),
291
+ agents: Object.create(null),
292
+ sections: []
293
+ };
294
+ const sections = new Set();
295
+ const sourceCounts = Object.fromEntries(Object.keys(CODEX_SCOPE_RANK).map((scope) => [scope, 0]));
296
+ let activeSourceCount = 0;
297
+
298
+ for (const layer of normalized) {
299
+ sourceCounts[layer.scope] += 1;
300
+ Object.assign(merged.root, layer.parsed.root);
301
+ Object.assign(merged.sandbox, layer.parsed.sandbox);
302
+ Object.assign(merged.agents, layer.parsed.agents);
303
+ for (const section of layer.parsed.sections) sections.add(section);
304
+ if (Object.keys(layer.parsed.root).length || Object.keys(layer.parsed.sandbox).length ||
305
+ Object.keys(layer.parsed.agents).length || layer.parsed.sections.length) {
306
+ activeSourceCount += 1;
307
+ }
308
+ }
309
+ merged.sections = [...sections].sort();
310
+
311
+ const result = inspectParsedCodexSettings(
312
+ merged,
313
+ 'merged Codex settings',
314
+ 'codex-settings-stack'
315
+ );
316
+ return {
317
+ ...result,
318
+ source: { type: 'local', format: 'codex-settings-stack', scopes: sourceCounts },
319
+ settingsContext: {
320
+ sourceCount: normalized.length,
321
+ activeSourceCount,
322
+ sourceCounts
323
+ }
324
+ };
325
+ }