trustdex 0.5.1 → 0.7.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.
Files changed (49) hide show
  1. package/README.ko.md +65 -7
  2. package/README.md +71 -7
  3. package/bin/trustdex.mjs +98 -1
  4. package/docs/ARCHITECTURE.ko.md +9 -2
  5. package/docs/ARCHITECTURE.md +11 -2
  6. package/docs/CLAUDE.ko.md +54 -1
  7. package/docs/CLAUDE.md +69 -1
  8. package/docs/CODEX.ko.md +50 -4
  9. package/docs/CODEX.md +62 -4
  10. package/docs/DEFENSIVE_USE_CASE.ko.md +85 -0
  11. package/docs/DEFENSIVE_USE_CASE.md +89 -0
  12. package/docs/EVALUATION.ko.md +46 -1
  13. package/docs/EVALUATION.md +48 -1
  14. package/docs/ROADMAP.ko.md +17 -2
  15. package/docs/ROADMAP.md +17 -2
  16. package/docs/SECURITY_RESEARCH.ko.md +7 -0
  17. package/docs/SECURITY_RESEARCH.md +7 -0
  18. package/docs/THREAT_MODEL.ko.md +8 -1
  19. package/docs/THREAT_MODEL.md +8 -1
  20. package/examples/adversarial/README.ko.md +31 -0
  21. package/examples/adversarial/README.md +31 -0
  22. package/examples/adversarial/drift-after.json +11 -0
  23. package/examples/adversarial/drift-before.json +8 -0
  24. package/examples/adversarial/expected.json +30 -0
  25. package/examples/adversarial/mcp-config.json +32 -0
  26. package/examples/adversarial/permissions/README.ko.md +15 -0
  27. package/examples/adversarial/permissions/README.md +15 -0
  28. package/examples/adversarial/permissions/claude-expanded.settings.json +30 -0
  29. package/examples/adversarial/permissions/claude-restrictive.settings.json +16 -0
  30. package/examples/adversarial/permissions/codex-expanded.toml +13 -0
  31. package/examples/adversarial/permissions/codex-restrictive.toml +13 -0
  32. package/examples/adversarial/permissions/expected.json +18 -0
  33. package/examples/adversarial/policy.json +10 -0
  34. package/examples/claude.mcp.json +24 -0
  35. package/examples/claude.policy.json +6 -0
  36. package/examples/claude.settings.json +26 -0
  37. package/examples/codex.config.toml +16 -0
  38. package/examples/codex.permissions.toml +13 -0
  39. package/examples/mcp.json +22 -0
  40. package/examples/trust-store.example.json +16 -0
  41. package/examples/trustdex.policy.json +14 -0
  42. package/package.json +5 -3
  43. package/schemas/permission-posture-diff-v1.schema.json +78 -0
  44. package/schemas/permission-posture-v1.schema.json +162 -0
  45. package/src/claude-settings.mjs +173 -0
  46. package/src/codex-config.mjs +9 -7
  47. package/src/codex-settings.mjs +247 -0
  48. package/src/policy-packs.mjs +38 -2
  49. package/src/posture.mjs +540 -0
@@ -0,0 +1,162 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/grigent/trustdex/blob/main/schemas/permission-posture-v1.schema.json",
4
+ "title": "TrustDex permission posture snapshot v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schemaVersion",
9
+ "kind",
10
+ "createdAt",
11
+ "runtime",
12
+ "policyDigest",
13
+ "posture",
14
+ "digest"
15
+ ],
16
+ "properties": {
17
+ "schemaVersion": { "const": 1 },
18
+ "kind": { "const": "agent-permission-posture" },
19
+ "createdAt": { "type": "string", "format": "date-time" },
20
+ "runtime": { "enum": ["claude", "codex"] },
21
+ "policyDigest": { "$ref": "#/$defs/digest" },
22
+ "digest": { "$ref": "#/$defs/digest" },
23
+ "posture": {
24
+ "type": "object",
25
+ "additionalProperties": false,
26
+ "required": ["action", "signals", "metadata"],
27
+ "properties": {
28
+ "action": { "enum": ["allow", "ask", "block"] },
29
+ "signals": {
30
+ "type": "array",
31
+ "uniqueItems": true,
32
+ "items": { "type": "string" }
33
+ },
34
+ "metadata": { "type": "object" }
35
+ }
36
+ }
37
+ },
38
+ "allOf": [
39
+ {
40
+ "if": { "properties": { "runtime": { "const": "claude" } } },
41
+ "then": {
42
+ "properties": {
43
+ "posture": {
44
+ "properties": {
45
+ "metadata": { "$ref": "#/$defs/claudeMetadata" }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ },
51
+ {
52
+ "if": { "properties": { "runtime": { "const": "codex" } } },
53
+ "then": {
54
+ "properties": {
55
+ "posture": {
56
+ "properties": {
57
+ "metadata": { "$ref": "#/$defs/codexMetadata" }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ ],
64
+ "$defs": {
65
+ "digest": {
66
+ "type": "string",
67
+ "pattern": "^[a-f0-9]{64}$"
68
+ },
69
+ "count": {
70
+ "type": "integer",
71
+ "minimum": 0
72
+ },
73
+ "claudeMetadata": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "required": [
77
+ "defaultMode",
78
+ "allowRuleCount",
79
+ "askRuleCount",
80
+ "denyRuleCount",
81
+ "allowCategories",
82
+ "additionalDirectoryCount",
83
+ "bypassDisabled",
84
+ "autoModeDisabled",
85
+ "outsideReadsBlocked",
86
+ "uninspectedRootKeyCount"
87
+ ],
88
+ "properties": {
89
+ "defaultMode": {
90
+ "type": ["string", "null"],
91
+ "enum": [null, "default", "manual", "acceptEdits", "plan", "dontAsk", "bypassPermissions", "auto"]
92
+ },
93
+ "allowRuleCount": { "$ref": "#/$defs/count" },
94
+ "askRuleCount": { "$ref": "#/$defs/count" },
95
+ "denyRuleCount": { "$ref": "#/$defs/count" },
96
+ "allowCategories": {
97
+ "type": "array",
98
+ "uniqueItems": true,
99
+ "items": { "enum": ["filesystem", "mcp", "network", "other", "shell", "subagent"] }
100
+ },
101
+ "additionalDirectoryCount": { "$ref": "#/$defs/count" },
102
+ "bypassDisabled": { "type": "boolean" },
103
+ "autoModeDisabled": { "type": "boolean" },
104
+ "outsideReadsBlocked": { "type": "boolean" },
105
+ "uninspectedRootKeyCount": { "$ref": "#/$defs/count" }
106
+ }
107
+ },
108
+ "codexMetadata": {
109
+ "type": "object",
110
+ "additionalProperties": false,
111
+ "required": [
112
+ "approvalPolicy",
113
+ "granularPromptCategories",
114
+ "approvalsReviewer",
115
+ "sandboxMode",
116
+ "defaultPermissionsKind",
117
+ "webSearch",
118
+ "networkAccess",
119
+ "additionalWritableRootCount",
120
+ "tempRootsExcluded",
121
+ "subagentsEnabled",
122
+ "uninspectedAgentKeyCount",
123
+ "uninspectedRootKeyCount",
124
+ "uninspectedSectionKindCount"
125
+ ],
126
+ "properties": {
127
+ "approvalPolicy": {
128
+ "type": ["string", "null"],
129
+ "enum": [null, "on-request", "never", "granular"]
130
+ },
131
+ "granularPromptCategories": {
132
+ "type": "array",
133
+ "uniqueItems": true,
134
+ "items": { "enum": ["mcp_elicitations", "request_permissions", "rules", "sandbox_approval", "skill_approval"] }
135
+ },
136
+ "approvalsReviewer": {
137
+ "type": ["string", "null"],
138
+ "enum": [null, "user", "auto_review"]
139
+ },
140
+ "sandboxMode": {
141
+ "type": ["string", "null"],
142
+ "enum": [null, "read-only", "workspace-write", "danger-full-access"]
143
+ },
144
+ "defaultPermissionsKind": {
145
+ "type": ["string", "null"],
146
+ "enum": [null, ":read-only", ":workspace", ":danger-full-access", "custom"]
147
+ },
148
+ "webSearch": {
149
+ "type": ["string", "null"],
150
+ "enum": [null, "disabled", "cached", "indexed", "live"]
151
+ },
152
+ "networkAccess": { "type": "boolean" },
153
+ "additionalWritableRootCount": { "$ref": "#/$defs/count" },
154
+ "tempRootsExcluded": { "type": "boolean" },
155
+ "subagentsEnabled": { "type": "boolean" },
156
+ "uninspectedAgentKeyCount": { "$ref": "#/$defs/count" },
157
+ "uninspectedRootKeyCount": { "$ref": "#/$defs/count" },
158
+ "uninspectedSectionKindCount": { "$ref": "#/$defs/count" }
159
+ }
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,173 @@
1
+ const SECRET_KEY_RE = /(token|secret|password|passwd|api[_-]?key|credential|auth)/i;
2
+ const PERMISSION_KEYS = new Set([
3
+ 'allow',
4
+ 'ask',
5
+ 'deny',
6
+ 'defaultMode',
7
+ 'additionalDirectories',
8
+ 'disableBypassPermissionsMode',
9
+ 'disableAutoMode',
10
+ 'blockReadsOutsideWorkingDirectories'
11
+ ]);
12
+ const MODES = new Set([
13
+ 'default',
14
+ 'manual',
15
+ 'acceptEdits',
16
+ 'plan',
17
+ 'dontAsk',
18
+ 'bypassPermissions',
19
+ 'auto'
20
+ ]);
21
+ const NON_PERMISSION_ROOT_KEYS = new Set(['$schema', 'permissions']);
22
+
23
+ function isObject(value) {
24
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
25
+ }
26
+
27
+ function requireStringArray(value, field) {
28
+ if (value === undefined) return [];
29
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) {
30
+ throw new Error(`Claude permissions.${field} must be an array of non-empty strings.`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function parseRule(rule, list) {
36
+ const value = rule.trim();
37
+ const match = value.match(/^([A-Za-z0-9_*.-]+)(?:\(([\s\S]*)\))?$/);
38
+ if (!match) throw new Error(`Invalid Claude permissions.${list} rule syntax.`);
39
+
40
+ const tool = match[1];
41
+ const specifier = match[2] === undefined ? null : match[2];
42
+ if (specifier !== null && !specifier.trim()) {
43
+ throw new Error(`Invalid empty specifier in Claude permissions.${list}.`);
44
+ }
45
+ if (list === 'allow' && (tool === '*' || tool === 'mcp__*' || (!tool.startsWith('mcp__') && tool.includes('*')))) {
46
+ throw new Error('Claude ignores unanchored wildcard allow rules; use an explicit tool or MCP server rule.');
47
+ }
48
+ if (tool.startsWith('mcp__') && specifier !== null) {
49
+ throw new Error('Claude skips MCP permission rules with parenthesized specifiers.');
50
+ }
51
+
52
+ return {
53
+ tool,
54
+ hasSpecifier: specifier !== null,
55
+ matchesAll: specifier === null || specifier.trim() === '*' || specifier.trim() === 'domain:*',
56
+ mcpWildcard: tool.startsWith('mcp__') && (tool.endsWith('__*') || !tool.includes('__', 5))
57
+ };
58
+ }
59
+
60
+ function toolCategory(tool) {
61
+ if (tool === 'Bash' || tool === 'PowerShell') return 'shell';
62
+ if (['Read', 'Edit', 'Write', 'Glob', 'Grep', 'NotebookEdit'].includes(tool)) return 'filesystem';
63
+ if (tool === 'WebFetch' || tool === 'WebSearch') return 'network';
64
+ if (tool === 'Agent') return 'subagent';
65
+ if (tool.startsWith('mcp__')) return 'mcp';
66
+ return 'other';
67
+ }
68
+
69
+ function validateDisable(value, field) {
70
+ if (value !== undefined && value !== 'disable') {
71
+ throw new Error(`Claude permissions.${field} must be "disable" when present.`);
72
+ }
73
+ }
74
+
75
+ function validatePermissions(value) {
76
+ if (value === undefined) return { permissions: {}, rules: { allow: [], ask: [], deny: [] } };
77
+ if (!isObject(value)) throw new Error('Claude settings permissions must be a JSON object.');
78
+
79
+ for (const key of Object.keys(value)) {
80
+ if (!PERMISSION_KEYS.has(key)) throw new Error(`Unsupported Claude permission setting: ${key}`);
81
+ }
82
+
83
+ const defaultMode = value.defaultMode;
84
+ if (defaultMode !== undefined && (!MODES.has(defaultMode) || typeof defaultMode !== 'string')) {
85
+ throw new Error('Unsupported Claude permissions.defaultMode value.');
86
+ }
87
+ const additionalDirectories = requireStringArray(value.additionalDirectories, 'additionalDirectories');
88
+ if (value.blockReadsOutsideWorkingDirectories !== undefined &&
89
+ typeof value.blockReadsOutsideWorkingDirectories !== 'boolean') {
90
+ throw new Error('Claude permissions.blockReadsOutsideWorkingDirectories must be a boolean.');
91
+ }
92
+ validateDisable(value.disableBypassPermissionsMode, 'disableBypassPermissionsMode');
93
+ validateDisable(value.disableAutoMode, 'disableAutoMode');
94
+
95
+ const rules = {};
96
+ for (const list of ['allow', 'ask', 'deny']) {
97
+ rules[list] = requireStringArray(value[list], list).map((rule) => parseRule(rule, list));
98
+ }
99
+ return { permissions: { ...value, additionalDirectories }, rules };
100
+ }
101
+
102
+ function inspectAllowRules(rules, signals) {
103
+ for (const rule of rules) {
104
+ signals.add('claude-auto-approve');
105
+ const category = toolCategory(rule.tool);
106
+ if (category === 'shell') signals.add('claude-shell-auto-approve');
107
+ if (category === 'filesystem') signals.add('claude-filesystem-auto-approve');
108
+ if (category === 'network') signals.add('claude-network-auto-approve');
109
+ if (category === 'subagent') signals.add('claude-subagent-auto-approve');
110
+ if (category === 'mcp') signals.add('claude-mcp-auto-approve');
111
+ if (rule.matchesAll && ['shell', 'filesystem', 'network', 'subagent'].includes(category)) {
112
+ signals.add('claude-broad-auto-approve');
113
+ }
114
+ if (rule.mcpWildcard) signals.add('claude-mcp-wildcard');
115
+ }
116
+ }
117
+
118
+ export function inspectClaudeSettings(settings, sourceName = 'settings.json') {
119
+ if (!isObject(settings)) throw new Error('Expected a Claude settings JSON object.');
120
+
121
+ const { permissions, rules } = validatePermissions(settings.permissions);
122
+ const signals = new Set();
123
+ inspectAllowRules(rules.allow, signals);
124
+
125
+ if (permissions.defaultMode === 'bypassPermissions') signals.add('claude-bypass-permissions');
126
+ if (permissions.defaultMode === 'auto') signals.add('claude-auto-mode');
127
+ if (permissions.defaultMode === 'acceptEdits') signals.add('claude-filesystem-auto-approve');
128
+ if (permissions.additionalDirectories?.length) signals.add('claude-additional-directories');
129
+ if (permissions.blockReadsOutsideWorkingDirectories === false) signals.add('claude-outside-read-access');
130
+
131
+ let envKeys = [];
132
+ if (settings.env !== undefined) {
133
+ if (!isObject(settings.env)) throw new Error('Claude settings env must be a JSON object.');
134
+ envKeys = Object.keys(settings.env).sort();
135
+ signals.add('claude-environment');
136
+ if (envKeys.some((key) => SECRET_KEY_RE.test(key))) signals.add('secret-env');
137
+ }
138
+ if (settings.hooks !== undefined) signals.add('claude-hooks');
139
+ if (settings.apiKeyHelper !== undefined) signals.add('claude-credential-helper');
140
+ if (settings.sandbox !== undefined) signals.add('claude-sandbox-settings');
141
+ if (settings.enabledPlugins !== undefined || settings.extraKnownMarketplaces !== undefined) {
142
+ signals.add('claude-extension-loading');
143
+ }
144
+
145
+ const inspectedAdjacentKeys = new Set([
146
+ 'env', 'hooks', 'apiKeyHelper', 'sandbox', 'enabledPlugins', 'extraKnownMarketplaces'
147
+ ]);
148
+ const uninspectedRootKeys = Object.keys(settings)
149
+ .filter((key) => !NON_PERMISSION_ROOT_KEYS.has(key) && !inspectedAdjacentKeys.has(key))
150
+ .sort();
151
+ if (uninspectedRootKeys.length) signals.add('uninspected-claude-settings');
152
+
153
+ const allowCategories = [...new Set(rules.allow.map((rule) => toolCategory(rule.tool)))].sort();
154
+ return {
155
+ name: sourceName,
156
+ kind: 'claude-settings',
157
+ source: { type: 'local', format: 'claude-settings', path: sourceName },
158
+ metadata: {
159
+ defaultMode: permissions.defaultMode || null,
160
+ allowRuleCount: rules.allow.length,
161
+ askRuleCount: rules.ask.length,
162
+ denyRuleCount: rules.deny.length,
163
+ allowCategories,
164
+ additionalDirectoryCount: permissions.additionalDirectories?.length || 0,
165
+ bypassDisabled: permissions.disableBypassPermissionsMode === 'disable',
166
+ autoModeDisabled: permissions.disableAutoMode === 'disable',
167
+ outsideReadsBlocked: permissions.blockReadsOutsideWorkingDirectories === true,
168
+ uninspectedRootKeys
169
+ },
170
+ signals: [...signals].sort(),
171
+ envKeys
172
+ };
173
+ }
@@ -1,6 +1,6 @@
1
1
  const CODEX_MCP_NESTED_SECTIONS = new Set(['env', 'http_headers', 'env_http_headers']);
2
2
 
3
- function stripComment(line) {
3
+ export function stripComment(line) {
4
4
  let quote = null;
5
5
  let escaped = false;
6
6
  for (let i = 0; i < line.length; i += 1) {
@@ -61,7 +61,7 @@ function splitTopLevel(value) {
61
61
  return out;
62
62
  }
63
63
 
64
- function unquote(value) {
64
+ export function unquote(value) {
65
65
  const raw = value.trim();
66
66
  if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
67
67
  return raw.slice(1, -1);
@@ -69,7 +69,7 @@ function unquote(value) {
69
69
  return raw;
70
70
  }
71
71
 
72
- function parseValue(raw) {
72
+ export function parseValue(raw) {
73
73
  const value = raw.trim();
74
74
  if (!value) return '';
75
75
  if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
@@ -79,7 +79,7 @@ function parseValue(raw) {
79
79
  return splitTopLevel(value.slice(1, -1)).map(parseValue);
80
80
  }
81
81
  if (value.startsWith('{') && value.endsWith('}')) {
82
- const object = {};
82
+ const object = Object.create(null);
83
83
  for (const part of splitTopLevel(value.slice(1, -1))) {
84
84
  const index = part.indexOf('=');
85
85
  if (index < 1) throw new Error(`Unsupported TOML inline table entry: ${part}`);
@@ -93,7 +93,7 @@ function parseValue(raw) {
93
93
  return value;
94
94
  }
95
95
 
96
- function valueComplete(value) {
96
+ export function valueComplete(value) {
97
97
  let square = 0;
98
98
  let curly = 0;
99
99
  let quote = null;
@@ -128,8 +128,10 @@ export function codexServerNameFromHeader(header) {
128
128
  /^mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))(?:\.(.*))?$/
129
129
  );
130
130
  if (!match) return null;
131
+ const name = match[1] || match[2] || match[3];
132
+ if (['__proto__', 'prototype', 'constructor'].includes(name)) return null;
131
133
  return {
132
- name: match[1] || match[2] || match[3],
134
+ name,
133
135
  nested: match[4] || ''
134
136
  };
135
137
  }
@@ -209,7 +211,7 @@ function applyAssignment(server, nested, key, value) {
209
211
 
210
212
  export function parseCodexMcpToml(text) {
211
213
  const lines = String(text || '').split(/\r?\n/);
212
- const servers = {};
214
+ const servers = Object.create(null);
213
215
  let section = null;
214
216
 
215
217
  for (let i = 0; i < lines.length; i += 1) {
@@ -0,0 +1,247 @@
1
+ import { parseValue, stripComment, unquote, valueComplete } from './codex-config.mjs';
2
+
3
+ const APPROVAL_POLICIES = new Set(['on-request', 'never']);
4
+ const APPROVAL_REVIEWERS = new Set(['user', 'auto_review']);
5
+ const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
6
+ const BUILTIN_PERMISSIONS = new Set([':read-only', ':workspace', ':danger-full-access']);
7
+ const WEB_SEARCH_MODES = new Set(['disabled', 'cached', 'indexed', 'live']);
8
+ const GRANULAR_KEYS = new Set([
9
+ 'sandbox_approval',
10
+ 'rules',
11
+ 'mcp_elicitations',
12
+ 'request_permissions',
13
+ 'skill_approval'
14
+ ]);
15
+ const SANDBOX_KEYS = new Set([
16
+ 'network_access',
17
+ 'writable_roots',
18
+ 'exclude_slash_tmp',
19
+ 'exclude_tmpdir_env_var'
20
+ ]);
21
+ const INSPECTED_ROOT_KEYS = new Set([
22
+ 'approval_policy',
23
+ 'approvals_reviewer',
24
+ 'sandbox_mode',
25
+ 'default_permissions',
26
+ 'web_search'
27
+ ]);
28
+
29
+ function isObject(value) {
30
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
31
+ }
32
+
33
+ function requireBoolean(value, key) {
34
+ if (typeof value !== 'boolean') throw new Error(`Codex ${key} must be a boolean.`);
35
+ return value;
36
+ }
37
+
38
+ function requireStringArray(value, key) {
39
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) {
40
+ throw new Error(`Codex ${key} must be an array of non-empty strings.`);
41
+ }
42
+ return value;
43
+ }
44
+
45
+ function parseApprovalPolicy(value) {
46
+ if (typeof value === 'string') {
47
+ if (!APPROVAL_POLICIES.has(value)) throw new Error('Unsupported Codex approval_policy value.');
48
+ return { mode: value, promptCategories: [] };
49
+ }
50
+ if (!isObject(value) || !isObject(value.granular) || Object.keys(value).some((key) => key !== 'granular')) {
51
+ throw new Error('Codex approval_policy must be "on-request", "never", or a granular policy.');
52
+ }
53
+ for (const [key, enabled] of Object.entries(value.granular)) {
54
+ if (!GRANULAR_KEYS.has(key) || typeof enabled !== 'boolean') {
55
+ throw new Error(`Unsupported Codex granular approval setting: ${key}`);
56
+ }
57
+ }
58
+ return {
59
+ mode: 'granular',
60
+ promptCategories: Object.entries(value.granular)
61
+ .filter(([, enabled]) => enabled)
62
+ .map(([key]) => key)
63
+ .sort()
64
+ };
65
+ }
66
+
67
+ function parseTomlAssignments(text) {
68
+ const lines = String(text || '').split(/\r?\n/);
69
+ const root = Object.create(null);
70
+ const sandbox = Object.create(null);
71
+ const agents = Object.create(null);
72
+ const approvalGranular = Object.create(null);
73
+ const sections = new Set();
74
+ let section = '';
75
+
76
+ for (let i = 0; i < lines.length; i += 1) {
77
+ const cleaned = stripComment(lines[i]).trim();
78
+ if (!cleaned) continue;
79
+
80
+ const header = cleaned.match(/^\[([^\]]+)\]$/);
81
+ if (header) {
82
+ section = header[1].trim();
83
+ if (section.startsWith('sandbox_workspace_write.') ||
84
+ (section.startsWith('approval_policy.') && section !== 'approval_policy.granular')) {
85
+ throw new Error(`Unsupported Codex security section: ${section}`);
86
+ }
87
+ sections.add(section);
88
+ continue;
89
+ }
90
+
91
+ const eq = cleaned.indexOf('=');
92
+ if (eq < 1) {
93
+ if (section === 'sandbox_workspace_write' || !section) {
94
+ throw new Error(`Unsupported Codex TOML line ${i + 1}: expected key = value`);
95
+ }
96
+ continue;
97
+ }
98
+
99
+ const key = unquote(cleaned.slice(0, eq).trim());
100
+ let raw = cleaned.slice(eq + 1).trim();
101
+ while (!valueComplete(raw)) {
102
+ i += 1;
103
+ if (i >= lines.length) throw new Error(`Unterminated TOML value for ${key}`);
104
+ raw += `\n${stripComment(lines[i]).trim()}`;
105
+ }
106
+ const value = parseValue(raw);
107
+
108
+ if (!section) {
109
+ if (key.startsWith('sandbox_workspace_write.')) {
110
+ const nestedKey = key.slice('sandbox_workspace_write.'.length);
111
+ if (!nestedKey || nestedKey.includes('.')) throw new Error(`Unsupported Codex sandbox key: ${key}`);
112
+ if (Object.hasOwn(sandbox, nestedKey)) throw new Error(`Duplicate Codex sandbox setting: ${nestedKey}`);
113
+ sandbox[nestedKey] = value;
114
+ } else if (key.startsWith('approval_policy.granular.')) {
115
+ const nestedKey = key.slice('approval_policy.granular.'.length);
116
+ if (!nestedKey || nestedKey.includes('.')) throw new Error(`Unsupported Codex approval setting: ${key}`);
117
+ if (Object.hasOwn(approvalGranular, nestedKey)) throw new Error(`Duplicate Codex approval setting: ${nestedKey}`);
118
+ approvalGranular[nestedKey] = value;
119
+ } else {
120
+ if (Object.hasOwn(root, key)) throw new Error(`Duplicate Codex root setting: ${key}`);
121
+ root[key] = value;
122
+ }
123
+ } else if (section === 'sandbox_workspace_write') {
124
+ if (Object.hasOwn(sandbox, key)) throw new Error(`Duplicate Codex sandbox setting: ${key}`);
125
+ sandbox[key] = value;
126
+ } else if (section === 'agents') {
127
+ if (Object.hasOwn(agents, key)) throw new Error(`Duplicate Codex agents setting: ${key}`);
128
+ agents[key] = value;
129
+ } else if (section === 'approval_policy.granular') {
130
+ if (Object.hasOwn(approvalGranular, key)) throw new Error(`Duplicate Codex approval setting: ${key}`);
131
+ approvalGranular[key] = value;
132
+ }
133
+ }
134
+
135
+ if (Object.keys(approvalGranular).length) {
136
+ if (root.approval_policy !== undefined) {
137
+ throw new Error('Codex approval_policy cannot be declared in more than one form.');
138
+ }
139
+ root.approval_policy = { granular: approvalGranular };
140
+ }
141
+
142
+ return { root, sandbox, agents, sections: [...sections].sort() };
143
+ }
144
+
145
+ function validateSandbox(sandbox) {
146
+ for (const key of Object.keys(sandbox)) {
147
+ if (!SANDBOX_KEYS.has(key)) throw new Error(`Unsupported Codex sandbox_workspace_write setting: ${key}`);
148
+ }
149
+ if (sandbox.network_access !== undefined) requireBoolean(sandbox.network_access, 'sandbox network_access');
150
+ if (sandbox.writable_roots !== undefined) requireStringArray(sandbox.writable_roots, 'sandbox writable_roots');
151
+ if (sandbox.exclude_slash_tmp !== undefined) requireBoolean(sandbox.exclude_slash_tmp, 'sandbox exclude_slash_tmp');
152
+ if (sandbox.exclude_tmpdir_env_var !== undefined) {
153
+ requireBoolean(sandbox.exclude_tmpdir_env_var, 'sandbox exclude_tmpdir_env_var');
154
+ }
155
+ }
156
+
157
+ export function inspectCodexSettingsText(text, sourceName = 'config.toml') {
158
+ const { root, sandbox, agents, sections } = parseTomlAssignments(text);
159
+ validateSandbox(sandbox);
160
+
161
+ const signals = new Set();
162
+ let approval = { mode: null, promptCategories: [] };
163
+ if (root.approval_policy !== undefined) {
164
+ approval = parseApprovalPolicy(root.approval_policy);
165
+ if (approval.mode === 'never') signals.add('codex-no-approvals');
166
+ }
167
+
168
+ if (root.approvals_reviewer !== undefined) {
169
+ if (!APPROVAL_REVIEWERS.has(root.approvals_reviewer)) {
170
+ throw new Error('Unsupported Codex approvals_reviewer value.');
171
+ }
172
+ if (root.approvals_reviewer === 'auto_review') signals.add('codex-auto-review');
173
+ }
174
+
175
+ if (root.sandbox_mode !== undefined) {
176
+ if (!SANDBOX_MODES.has(root.sandbox_mode)) throw new Error('Unsupported Codex sandbox_mode value.');
177
+ if (root.sandbox_mode === 'workspace-write') signals.add('codex-workspace-write');
178
+ if (root.sandbox_mode === 'danger-full-access') signals.add('codex-full-access');
179
+ }
180
+
181
+ if (root.default_permissions !== undefined) {
182
+ if (typeof root.default_permissions !== 'string' || !root.default_permissions.trim()) {
183
+ throw new Error('Codex default_permissions must be a non-empty string.');
184
+ }
185
+ if (root.sandbox_mode !== undefined || sections.includes('sandbox_workspace_write') || Object.keys(sandbox).length) {
186
+ throw new Error('Codex default_permissions cannot be combined with sandbox_mode or sandbox_workspace_write.');
187
+ }
188
+ if (root.default_permissions === ':workspace') signals.add('codex-workspace-write');
189
+ else if (root.default_permissions === ':danger-full-access') signals.add('codex-full-access');
190
+ else if (!BUILTIN_PERMISSIONS.has(root.default_permissions)) signals.add('codex-custom-permissions');
191
+ }
192
+
193
+ if (root.web_search !== undefined) {
194
+ if (!WEB_SEARCH_MODES.has(root.web_search)) throw new Error('Unsupported Codex web_search value.');
195
+ if (root.web_search === 'live') signals.add('codex-live-web-search');
196
+ }
197
+
198
+ if (sandbox.network_access === true) signals.add('codex-network-access');
199
+ const writableRoots = sandbox.writable_roots || [];
200
+ if (writableRoots.length) signals.add('codex-additional-writable-roots');
201
+ if (sandbox.exclude_slash_tmp === false || sandbox.exclude_tmpdir_env_var === false) {
202
+ signals.add('codex-temp-write-access');
203
+ }
204
+ if (agents.enabled !== undefined) {
205
+ requireBoolean(agents.enabled, 'agents.enabled');
206
+ if (agents.enabled) signals.add('codex-subagents-enabled');
207
+ }
208
+ const uninspectedAgentKeys = Object.keys(agents).filter((key) => key !== 'enabled').sort();
209
+ if (uninspectedAgentKeys.length) signals.add('codex-subagent-settings');
210
+
211
+ const uninspectedRootKeys = Object.keys(root).filter((key) => !INSPECTED_ROOT_KEYS.has(key)).sort();
212
+ if (uninspectedRootKeys.length) signals.add('uninspected-codex-settings');
213
+
214
+ const sectionKinds = new Set(sections.map((section) => section.split('.')[0]));
215
+ sectionKinds.delete('sandbox_workspace_write');
216
+ sectionKinds.delete('approval_policy');
217
+ sectionKinds.delete('mcp_servers');
218
+ if (!uninspectedAgentKeys.length) sectionKinds.delete('agents');
219
+ if (sectionKinds.has('permissions')) signals.add('codex-custom-permissions');
220
+ if (sectionKinds.has('apps') || sectionKinds.has('plugins')) signals.add('codex-extension-settings');
221
+ if (sectionKinds.size) signals.add('uninspected-codex-sections');
222
+
223
+ return {
224
+ name: sourceName,
225
+ kind: 'codex-settings',
226
+ source: { type: 'local', format: 'codex-settings', path: sourceName },
227
+ metadata: {
228
+ approvalPolicy: approval.mode,
229
+ granularPromptCategories: approval.promptCategories,
230
+ approvalsReviewer: root.approvals_reviewer || null,
231
+ sandboxMode: root.sandbox_mode || null,
232
+ defaultPermissionsKind: BUILTIN_PERMISSIONS.has(root.default_permissions)
233
+ ? root.default_permissions
234
+ : root.default_permissions ? 'custom' : null,
235
+ webSearch: root.web_search || null,
236
+ networkAccess: sandbox.network_access === true,
237
+ additionalWritableRootCount: writableRoots.length,
238
+ tempRootsExcluded: sandbox.exclude_slash_tmp === true && sandbox.exclude_tmpdir_env_var === true,
239
+ subagentsEnabled: agents.enabled === true,
240
+ uninspectedAgentKeys,
241
+ uninspectedRootKeys,
242
+ uninspectedSectionKinds: [...sectionKinds].sort()
243
+ },
244
+ signals: [...signals].sort(),
245
+ envKeys: []
246
+ };
247
+ }