audit-tools 0.31.3 → 0.32.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 (65) hide show
  1. package/README.md +60 -20
  2. package/dist/audit/cli/auditStep.d.ts +6 -3
  3. package/dist/audit/cli/auditStep.d.ts.map +1 -1
  4. package/dist/audit/cli/auditStep.js +116 -19
  5. package/dist/audit/cli/auditStep.js.map +1 -1
  6. package/dist/audit/cli/dispatch.d.ts.map +1 -1
  7. package/dist/audit/cli/dispatch.js +41 -2
  8. package/dist/audit/cli/dispatch.js.map +1 -1
  9. package/dist/audit/cli/mergeAndIngestCommand.d.ts.map +1 -1
  10. package/dist/audit/cli/mergeAndIngestCommand.js +14 -1
  11. package/dist/audit/cli/mergeAndIngestCommand.js.map +1 -1
  12. package/dist/remediate/dispatch/ownershipScheduler.d.ts +11 -0
  13. package/dist/remediate/dispatch/ownershipScheduler.d.ts.map +1 -1
  14. package/dist/remediate/dispatch/ownershipScheduler.js +20 -6
  15. package/dist/remediate/dispatch/ownershipScheduler.js.map +1 -1
  16. package/dist/remediate/phases/plan.d.ts +16 -6
  17. package/dist/remediate/phases/plan.d.ts.map +1 -1
  18. package/dist/remediate/phases/plan.js +38 -72
  19. package/dist/remediate/phases/plan.js.map +1 -1
  20. package/dist/remediate/state/types.d.ts +22 -0
  21. package/dist/remediate/state/types.d.ts.map +1 -1
  22. package/dist/remediate/state/types.js +8 -0
  23. package/dist/remediate/state/types.js.map +1 -1
  24. package/dist/remediate/steps/dispatch.d.ts +55 -0
  25. package/dist/remediate/steps/dispatch.d.ts.map +1 -1
  26. package/dist/remediate/steps/dispatch.js +148 -18
  27. package/dist/remediate/steps/dispatch.js.map +1 -1
  28. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  29. package/dist/remediate/steps/nextStep.js +79 -7
  30. package/dist/remediate/steps/nextStep.js.map +1 -1
  31. package/dist/remediate/steps/rollingSession.d.ts.map +1 -1
  32. package/dist/remediate/steps/rollingSession.js +2 -3
  33. package/dist/remediate/steps/rollingSession.js.map +1 -1
  34. package/dist/remediate/steps/types.d.ts +1 -1
  35. package/dist/remediate/steps/types.d.ts.map +1 -1
  36. package/dist/remediate/steps/types.js.map +1 -1
  37. package/dist/shared/index.d.ts +3 -1
  38. package/dist/shared/index.d.ts.map +1 -1
  39. package/dist/shared/index.js +2 -1
  40. package/dist/shared/index.js.map +1 -1
  41. package/dist/shared/io/auditToolsPaths.d.ts +20 -0
  42. package/dist/shared/io/auditToolsPaths.d.ts.map +1 -1
  43. package/dist/shared/io/auditToolsPaths.js +24 -0
  44. package/dist/shared/io/auditToolsPaths.js.map +1 -1
  45. package/dist/shared/io/gitignoreArtifacts.d.ts +1 -1
  46. package/dist/shared/io/stepContractWriter.d.ts +12 -4
  47. package/dist/shared/io/stepContractWriter.d.ts.map +1 -1
  48. package/dist/shared/io/stepContractWriter.js +81 -11
  49. package/dist/shared/io/stepContractWriter.js.map +1 -1
  50. package/dist/shared/quota/claimLease.d.ts +32 -0
  51. package/dist/shared/quota/claimLease.d.ts.map +1 -0
  52. package/dist/shared/quota/claimLease.js +66 -0
  53. package/dist/shared/quota/claimLease.js.map +1 -0
  54. package/dist/shared/quota/claimRegistry.d.ts +35 -1
  55. package/dist/shared/quota/claimRegistry.d.ts.map +1 -1
  56. package/dist/shared/quota/claimRegistry.js +74 -7
  57. package/dist/shared/quota/claimRegistry.js.map +1 -1
  58. package/docs/audit-pkg/development.md +1 -1
  59. package/package.json +5 -8
  60. package/remediate-code.mjs +23 -2
  61. package/wrapper/remediate-code-wrapper-install-hosts.mjs +1187 -0
  62. package/wrapper/remediate-code-wrapper-install-renderers.mjs +58 -0
  63. package/wrapper/remediate-code-wrapper-io.mjs +145 -0
  64. package/wrapper/remediate-code-wrapper-legacy.mjs +125 -0
  65. package/wrapper/remediate-code-wrapper-opencode.mjs +201 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Per-host asset render functions for remediate-code install.
3
+ *
4
+ * Every host asset is derived from the ONE canonical loader prompt body via the
5
+ * shared `renderHostAsset` helper (`audit-tools/shared`). These functions are
6
+ * thin wrappers that pass the canonical body through with the right `kind` and
7
+ * `toolName`; they do NOT re-author per-host loader prose. That makes it
8
+ * impossible for a host asset to drift out of sync with the canonical body — to
9
+ * silently drop the next-step capability handshake (including `--host-models`) or
10
+ * to embed a wrong in-repo entrypoint. A no-drift guard test enforces this.
11
+ */
12
+
13
+ import { renderHostAsset } from 'audit-tools/shared';
14
+
15
+ export const INSTALLED_PROMPT_FILENAME = 'remediate-code.import.md';
16
+
17
+ const TOOL_NAME = 'remediate-code';
18
+ const ASSET_DESCRIPTION =
19
+ 'Autonomous local-loop remediation — loads one backend-rendered remediation step at a time';
20
+
21
+ /** Render the VS Code remediator agent markdown file from the canonical body. */
22
+ export function renderVSCodeAgentFile(promptBody) {
23
+ return renderHostAsset('vscode-agent', {
24
+ promptBody,
25
+ toolName: TOOL_NAME,
26
+ description:
27
+ 'Plan and orchestrate /remediate-code through the next-step machine before making code changes.',
28
+ });
29
+ }
30
+
31
+ /** Render the Codex re-run automation recipe markdown from the canonical body. */
32
+ export function renderCodexAutomationRecipe(promptBody) {
33
+ return renderHostAsset('codex-recipe', {
34
+ promptBody,
35
+ toolName: TOOL_NAME,
36
+ });
37
+ }
38
+
39
+ /** Render the Antigravity planning-mode guide from the canonical body. */
40
+ export function renderAntigravityPlanningGuide(promptBody) {
41
+ return renderHostAsset('antigravity-guide', {
42
+ promptBody,
43
+ toolName: TOOL_NAME,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Render the Gemini/Antigravity slash command TOML for a given prompt body.
49
+ * Escapes backslashes and double-quotes so the TOML multi-line basic string
50
+ * is valid regardless of prompt content.
51
+ */
52
+ export function renderGeminiCommandToml(promptBody) {
53
+ return renderHostAsset('gemini-toml', {
54
+ promptBody,
55
+ toolName: TOOL_NAME,
56
+ description: ASSET_DESCRIPTION,
57
+ });
58
+ }
@@ -0,0 +1,145 @@
1
+ import { access, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+
5
+ export async function fileExists(path) {
6
+ try {
7
+ await access(path, constants.F_OK);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ export async function newestMtimeMs(path) {
15
+ const stats = await stat(path);
16
+ if (!stats.isDirectory()) {
17
+ return stats.mtimeMs;
18
+ }
19
+
20
+ let newest = stats.mtimeMs;
21
+ const entries = await readdir(path, { withFileTypes: true });
22
+ for (const entry of entries) {
23
+ const childPath = `${path}/${entry.name}`;
24
+ if (entry.isDirectory()) {
25
+ newest = Math.max(newest, await newestMtimeMs(childPath));
26
+ continue;
27
+ }
28
+ if (entry.isFile()) {
29
+ newest = Math.max(newest, (await stat(childPath)).mtimeMs);
30
+ }
31
+ }
32
+ return newest;
33
+ }
34
+
35
+ export async function readTextIfExists(path) {
36
+ try {
37
+ return await readFile(path, 'utf8');
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export async function readJson(path, description) {
44
+ const content = await readFile(path, 'utf8');
45
+ try {
46
+ return JSON.parse(content);
47
+ } catch (error) {
48
+ throw new Error(
49
+ `${description} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
50
+ );
51
+ }
52
+ }
53
+
54
+ export async function writeGeneratedMarkdown(targetPath, content) {
55
+ const existed = await fileExists(targetPath);
56
+ await mkdir(dirname(targetPath), { recursive: true });
57
+ await writeFile(targetPath, content, 'utf8');
58
+ return {
59
+ path: targetPath,
60
+ mode: existed ? 'updated' : 'created',
61
+ };
62
+ }
63
+
64
+ export async function writeGeneratedJson(targetPath, value) {
65
+ const existed = await fileExists(targetPath);
66
+ await mkdir(dirname(targetPath), { recursive: true });
67
+ await writeFile(targetPath, JSON.stringify(value, null, 2) + '\n', 'utf8');
68
+ return {
69
+ path: targetPath,
70
+ mode: existed ? 'updated' : 'created',
71
+ };
72
+ }
73
+
74
+ async function readJsonObjectIfExists(targetPath, description) {
75
+ if (!(await fileExists(targetPath))) {
76
+ return {};
77
+ }
78
+
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(await readFile(targetPath, 'utf8'));
82
+ } catch (error) {
83
+ throw new Error(
84
+ `${description} exists but is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
85
+ );
86
+ }
87
+
88
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
89
+ throw new Error(`${description} must be a JSON object when it already exists.`);
90
+ }
91
+
92
+ return parsed;
93
+ }
94
+
95
+ export async function writeMergedGeneratedJson(targetPath, description, buildValue) {
96
+ const existed = await fileExists(targetPath);
97
+ const existing = await readJsonObjectIfExists(targetPath, description);
98
+ await mkdir(dirname(targetPath), { recursive: true });
99
+ await writeFile(
100
+ targetPath,
101
+ JSON.stringify(buildValue(existing), null, 2) + '\n',
102
+ 'utf8',
103
+ );
104
+ return {
105
+ path: targetPath,
106
+ mode: existed ? 'updated' : 'created',
107
+ };
108
+ }
109
+
110
+ const INSTALL_MARKER_START = '<!-- remediate-code:begin -->';
111
+ const INSTALL_MARKER_END = '<!-- remediate-code:end -->';
112
+
113
+ function normalizeNewlines(value) {
114
+ return value.replace(/\r\n/g, '\n');
115
+ }
116
+
117
+ function upsertManagedBlock(existingContent, blockContent) {
118
+ const normalized = normalizeNewlines(existingContent);
119
+ const blockPattern = new RegExp(
120
+ `${INSTALL_MARKER_START}[\\s\\S]*?${INSTALL_MARKER_END}`,
121
+ 'u',
122
+ );
123
+
124
+ if (blockPattern.test(normalized)) {
125
+ return normalized.replace(blockPattern, blockContent);
126
+ }
127
+
128
+ if (normalized.trim().length === 0) {
129
+ return `${blockContent}\n`;
130
+ }
131
+
132
+ return `${normalized.replace(/\s+$/u, '')}\n\n${blockContent}\n`;
133
+ }
134
+
135
+ export async function writeManagedMarkdown(targetPath, blockContent) {
136
+ const existed = await fileExists(targetPath);
137
+ const existingContent = existed ? await readFile(targetPath, 'utf8') : '';
138
+ const nextContent = upsertManagedBlock(existingContent, blockContent);
139
+ await mkdir(dirname(targetPath), { recursive: true });
140
+ await writeFile(targetPath, nextContent, 'utf8');
141
+ return {
142
+ path: targetPath,
143
+ mode: existed ? 'updated' : 'created',
144
+ };
145
+ }
@@ -0,0 +1,125 @@
1
+ import { readdir, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readTextIfExists } from './remediate-code-wrapper-io.mjs';
4
+
5
+ function normalizeNewlines(value) {
6
+ return value.replace(/\r\n/g, '\n');
7
+ }
8
+
9
+ function looksLikeRemediateCodeSkill(content) {
10
+ const normalized = normalizeNewlines(content);
11
+ return (
12
+ /^name:\s*remediate-code\b/mu.test(normalized)
13
+ || normalized.includes('Conversation-first remediation of audit findings or feedback.')
14
+ || normalized.includes('The canonical entrypoint is `/remediate-code` in conversation.')
15
+ );
16
+ }
17
+
18
+ function looksLikeRemediateCodePrompt(content) {
19
+ const normalized = normalizeNewlines(content);
20
+ return (
21
+ normalized.includes('# `/remediate-code`')
22
+ && (
23
+ normalized.includes('remediate-code orchestrator')
24
+ || normalized.includes('Autonomous local-loop remediation')
25
+ || normalized.includes('Conversation-first remediation')
26
+ )
27
+ );
28
+ }
29
+
30
+ function looksLikeRemediateCodeInterfaceMetadata(content) {
31
+ const normalized = normalizeNewlines(content);
32
+ return (
33
+ normalized.includes('remediate-code')
34
+ && (
35
+ normalized.includes('display_name:')
36
+ || normalized.includes('short_description:')
37
+ || normalized.includes('default_prompt:')
38
+ )
39
+ && (
40
+ normalized.includes('/remediate-code')
41
+ || normalized.includes('Start /remediate-code')
42
+ )
43
+ );
44
+ }
45
+
46
+ export async function buildLegacyRemediateCodeSurfaceTargets(root) {
47
+ const targets = [
48
+ {
49
+ host: 'codex',
50
+ surface: 'skill',
51
+ path: join(root, '.codex', 'skills', 'remediate-code', 'SKILL.md'),
52
+ matches: looksLikeRemediateCodeSkill,
53
+ },
54
+ {
55
+ host: 'codex',
56
+ surface: 'prompt',
57
+ path: join(root, '.codex', 'skills', 'remediate-code', 'remediate-code.prompt.md'),
58
+ matches: looksLikeRemediateCodePrompt,
59
+ },
60
+ {
61
+ host: 'opencode',
62
+ surface: 'command',
63
+ path: join(root, '.opencode', 'commands', 'remediate-code.md'),
64
+ matches: looksLikeRemediateCodePrompt,
65
+ },
66
+ {
67
+ host: 'opencode',
68
+ surface: 'skill',
69
+ path: join(root, '.opencode', 'skills', 'remediate-code', 'SKILL.md'),
70
+ matches: looksLikeRemediateCodeSkill,
71
+ },
72
+ {
73
+ host: 'opencode',
74
+ surface: 'prompt',
75
+ path: join(root, '.opencode', 'skills', 'remediate-code', 'remediate-code.prompt.md'),
76
+ matches: looksLikeRemediateCodePrompt,
77
+ },
78
+ {
79
+ host: 'claude',
80
+ surface: 'command',
81
+ path: join(root, '.claude', 'commands', 'remediate-code.md'),
82
+ matches: looksLikeRemediateCodePrompt,
83
+ },
84
+ ];
85
+
86
+ const codexAgentDir = join(root, '.codex', 'skills', 'remediate-code', 'agents');
87
+ const codexAgentEntries = await readdir(codexAgentDir).catch(() => []);
88
+ for (const entry of codexAgentEntries) {
89
+ targets.push({
90
+ host: 'codex',
91
+ surface: 'interface-metadata',
92
+ path: join(codexAgentDir, entry),
93
+ matches: looksLikeRemediateCodeInterfaceMetadata,
94
+ });
95
+ }
96
+
97
+ return targets;
98
+ }
99
+
100
+ export async function findLegacyRemediateCodeSurfaceFiles(root) {
101
+ const matches = [];
102
+ for (const target of await buildLegacyRemediateCodeSurfaceTargets(root)) {
103
+ const existing = await readTextIfExists(target.path);
104
+ if (existing !== null && target.matches(existing)) {
105
+ matches.push(target.path);
106
+ }
107
+ }
108
+ return matches;
109
+ }
110
+
111
+ export async function removeLegacyRemediateCodeSurfaceFiles(root) {
112
+ const removed = [];
113
+ for (const target of await buildLegacyRemediateCodeSurfaceTargets(root)) {
114
+ const existing = await readTextIfExists(target.path);
115
+ if (existing === null || !target.matches(existing)) {
116
+ continue;
117
+ }
118
+ await unlink(target.path);
119
+ removed.push({
120
+ path: target.path,
121
+ mode: 'removed',
122
+ });
123
+ }
124
+ return removed;
125
+ }
@@ -0,0 +1,201 @@
1
+ // Best-effort import of shared OpenCode permission helpers.
2
+ // On a fresh checkout before `npm run build`, the dist may not exist yet;
3
+ // buildMergedOpenCodeProjectConfig throws a clear error in that case.
4
+ let mergeOpenCodeAgentPermissionRule;
5
+ let withoutOpenCodeWildcard;
6
+ export let sharedOpenCodePermissions = false;
7
+
8
+ try {
9
+ const shared = await import('audit-tools/shared');
10
+ mergeOpenCodeAgentPermissionRule = shared.mergeOpenCodeAgentPermissionRule;
11
+ withoutOpenCodeWildcard = shared.withoutOpenCodeWildcard;
12
+ sharedOpenCodePermissions = true;
13
+ } catch {
14
+ // shared not yet built — callers that need merge logic will throw below
15
+ }
16
+
17
+ export const OPENCODE_REMEDIATE_EDIT_PERMISSION = {
18
+ '*': 'ask',
19
+ '.remediate-code/**': 'allow',
20
+ '.audit-tools/**': 'allow',
21
+ };
22
+
23
+ export const OPENCODE_REMEDIATE_EXTERNAL_DIRECTORY_PERMISSION = { '*': 'allow' };
24
+
25
+ // Subcommands allowed for both the global bin and the dev wrapper.
26
+ // Adding or removing a subcommand here applies to both invocation forms.
27
+ const REMEDIATE_CODE_ALLOWED_SUBCOMMANDS = [
28
+ 'ensure*',
29
+ 'next-step*',
30
+ 'prepare-implement-dispatch*',
31
+ 'merge-implement-results*',
32
+ 'accept-node*',
33
+ 'validate*',
34
+ ];
35
+
36
+ function buildRemediateBashPermissions() {
37
+ /** @type {Record<string, 'allow' | 'deny'>} */
38
+ const perm = { '*': 'allow' };
39
+ // Allow rules: bare bin and each subcommand for both the global bin and wrapper.
40
+ perm['remediate-code'] = 'allow';
41
+ for (const sub of REMEDIATE_CODE_ALLOWED_SUBCOMMANDS) {
42
+ perm[`remediate-code ${sub}`] = 'allow';
43
+ perm[`*remediate-code.mjs* ${sub}`] = 'allow';
44
+ }
45
+ perm['*remediate-code.mjs'] = 'allow';
46
+ perm['git status*'] = 'allow';
47
+ perm['git diff*'] = 'allow';
48
+ perm['grep *'] = 'allow';
49
+ perm['Select-String *'] = 'allow';
50
+ perm['rm *'] = 'deny';
51
+ return perm;
52
+ }
53
+
54
+ export const OPENCODE_REMEDIATE_BASH_PERMISSION = buildRemediateBashPermissions();
55
+
56
+ function renderOpenCodeExternalDirectoryPermission() {
57
+ return { '*': 'allow' };
58
+ }
59
+
60
+ export function renderOpenCodePermissionConfig() {
61
+ return {
62
+ read: 'allow',
63
+ glob: 'allow',
64
+ grep: 'allow',
65
+ external_directory: renderOpenCodeExternalDirectoryPermission(),
66
+ edit: { ...OPENCODE_REMEDIATE_EDIT_PERMISSION },
67
+ bash: { ...OPENCODE_REMEDIATE_BASH_PERMISSION },
68
+ };
69
+ }
70
+
71
+ export function renderOpenCodeProjectConfig(_root) {
72
+ const remediatePermission = renderOpenCodePermissionConfig();
73
+ return {
74
+ $schema: 'https://opencode.ai/config.json',
75
+ permission: remediatePermission,
76
+ agent: {
77
+ remediator: {
78
+ description:
79
+ 'Remediation orchestration agent for the /remediate-code workflow.',
80
+ permission: {
81
+ ...remediatePermission,
82
+ 'remediator_*': 'allow',
83
+ question: 'allow',
84
+ task: 'allow',
85
+ },
86
+ },
87
+ },
88
+ };
89
+ }
90
+
91
+ function objectValue(value) {
92
+ return value && typeof value === 'object' && !Array.isArray(value)
93
+ ? value
94
+ : {};
95
+ }
96
+
97
+ export function removeManagedOpenCodeCommand(commandConfig) {
98
+ const command = objectValue(commandConfig);
99
+ const { 'remediate-code': _managedRemediateCodeCommand, ...remaining } = command;
100
+ return remaining;
101
+ }
102
+
103
+ export function assertOpenCodeRemediatePermissionConfig(permissionConfig, label) {
104
+ for (const tool of ['read', 'glob', 'grep']) {
105
+ if (permissionConfig?.[tool] !== 'allow') {
106
+ throw new Error(`OpenCode ${label}.${tool} must be allow. Run "remediate-code install --host opencode".`);
107
+ }
108
+ }
109
+ const externalDirectory = permissionConfig?.external_directory;
110
+ if (!externalDirectory || typeof externalDirectory !== 'object' || Array.isArray(externalDirectory)) {
111
+ throw new Error(`OpenCode ${label}.external_directory must set "*" to "allow". Run "remediate-code install --host opencode".`);
112
+ }
113
+ if (externalDirectory['*'] !== 'allow') {
114
+ throw new Error(`OpenCode ${label}.external_directory must set "*" to "allow". Run "remediate-code install --host opencode".`);
115
+ }
116
+ const edit = permissionConfig?.edit;
117
+ const bash = permissionConfig?.bash;
118
+ if (!edit || typeof edit !== 'object' || Array.isArray(edit)) {
119
+ throw new Error(`OpenCode ${label}.edit must allow remediate-owned file paths. Run "remediate-code install --host opencode".`);
120
+ }
121
+ for (const pattern of ['.remediate-code/**', '.audit-tools/**']) {
122
+ if (edit[pattern] !== 'allow') {
123
+ throw new Error(`OpenCode ${label}.edit must allow ${pattern}. Run "remediate-code install --host opencode".`);
124
+ }
125
+ }
126
+ if (!bash || typeof bash !== 'object' || Array.isArray(bash)) {
127
+ throw new Error(`OpenCode ${label}.bash must allow remediate-code commands. Run "remediate-code install --host opencode".`);
128
+ }
129
+ for (const pattern of [
130
+ 'remediate-code',
131
+ 'remediate-code ensure*',
132
+ 'remediate-code next-step*',
133
+ 'remediate-code prepare-implement-dispatch*',
134
+ 'remediate-code merge-implement-results*',
135
+ 'remediate-code accept-node*',
136
+ '*remediate-code.mjs',
137
+ '*remediate-code.mjs* next-step*',
138
+ '*remediate-code.mjs* merge-implement-results*',
139
+ '*remediate-code.mjs* accept-node*',
140
+ ]) {
141
+ if (bash[pattern] !== 'allow') {
142
+ throw new Error(`OpenCode ${label}.bash must allow ${pattern}. Run "remediate-code install --host opencode".`);
143
+ }
144
+ }
145
+ }
146
+
147
+ function mergePermissionBlock(existingPermission, generatedPermission) {
148
+ const existing = objectValue(existingPermission);
149
+ return {
150
+ ...generatedPermission,
151
+ ...existing,
152
+ read: generatedPermission.read,
153
+ glob: generatedPermission.glob,
154
+ grep: generatedPermission.grep,
155
+ external_directory: mergeOpenCodeAgentPermissionRule(
156
+ existing.external_directory,
157
+ generatedPermission.external_directory,
158
+ OPENCODE_REMEDIATE_EXTERNAL_DIRECTORY_PERMISSION,
159
+ ),
160
+ edit: mergeOpenCodeAgentPermissionRule(
161
+ existing.edit,
162
+ generatedPermission.edit,
163
+ withoutOpenCodeWildcard(OPENCODE_REMEDIATE_EDIT_PERMISSION),
164
+ ),
165
+ bash: mergeOpenCodeAgentPermissionRule(
166
+ existing.bash,
167
+ generatedPermission.bash,
168
+ withoutOpenCodeWildcard(OPENCODE_REMEDIATE_BASH_PERMISSION),
169
+ ),
170
+ };
171
+ }
172
+
173
+ export function buildMergedOpenCodeProjectConfig(existing, root) {
174
+ if (!sharedOpenCodePermissions) {
175
+ throw new Error(
176
+ 'audit-tools/shared is not available. Run "npm run build" before deploying OpenCode config.',
177
+ );
178
+ }
179
+ const generated = renderOpenCodeProjectConfig(root);
180
+ const mergedMcp = objectValue(existing.mcp);
181
+ delete mergedMcp.remediator;
182
+ const existingRemediator = objectValue(objectValue(existing.agent).remediator);
183
+ return {
184
+ ...existing,
185
+ $schema: existing.$schema ?? generated.$schema,
186
+ command: removeManagedOpenCodeCommand(existing.command),
187
+ mcp: mergedMcp,
188
+ permission: mergePermissionBlock(existing.permission, generated.permission),
189
+ agent: {
190
+ ...objectValue(existing.agent),
191
+ remediator: {
192
+ ...existingRemediator,
193
+ ...generated.agent.remediator,
194
+ permission: mergePermissionBlock(
195
+ existingRemediator.permission,
196
+ generated.agent.remediator.permission,
197
+ ),
198
+ },
199
+ },
200
+ };
201
+ }