@ngockhoale/ukit 2.7.1 → 2.7.3
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.
- package/CHANGELOG.md +72 -0
- package/README.md +1 -3
- package/manifests/hostCapabilities.yaml +0 -10
- package/manifests/instructionRules.yaml +0 -16
- package/manifests/platform.full.yaml +2 -16
- package/package.json +3 -5
- package/scripts/perf/audit-perf.mjs +4 -4
- package/scripts/perf/perf-findings.json +420 -0
- package/src/cli/adapters.js +10 -13
- package/src/cli/commands/doctor.js +60 -10
- package/src/cli/index.js +2 -2
- package/src/core/ompConfigMerge.js +19 -0
- package/src/core/opencodeSteer.js +37 -0
- package/src/core/permissionDoctor.js +319 -0
- package/src/core/permissionPolicy.js +179 -0
- package/src/core/projectImportant.js +14 -8
- package/src/core/runInstallPipeline.js +25 -0
- package/src/core/runtimeConfig.js +43 -4
- package/src/core/status.js +6 -4
- package/src/core/unattendedDoctor.js +9 -5
- package/src/manifest/selectItems.js +0 -1
- package/src/render/buildVariables.js +10 -1
- package/templates/.claude/agents/bug-debugger.md +1 -1
- package/templates/.claude/agents/feature-implementer.md +1 -1
- package/templates/.claude/agents/ukit-small-task-maintainer.md +2 -2
- package/templates/.claude/commands/ukit/handoff-create.md +4 -4
- package/templates/.claude/commands/ukit/handoff-fullstack.md +4 -4
- package/templates/.claude/commands/ukit/handoff-implement.md +2 -2
- package/templates/.claude/commands/ukit/handoff-review.md +2 -2
- package/templates/.claude/settings.json +14 -6
- package/templates/.claude/skills/update-status/SKILL.md +1 -1
- package/templates/.claude/ukit/index/route-task.mjs +7 -2
- package/templates/.claude/ukit/skill-router-state.json +1 -0
- package/templates/.codex/README.md +1 -1
- package/templates/.codex/settings.json +0 -1
- package/templates/.omp/agents/bug-debugger.md +1 -1
- package/templates/.omp/agents/feature-implementer.md +1 -1
- package/templates/.omp/agents/ukit-small-task-maintainer.md +2 -2
- package/templates/.omp/config.yml +13 -7
- package/templates/.ukit/storage/cache/hook-latency/unknown.jsonl +4 -0
- package/templates/AGENTS.md +4 -13
- package/templates/CLAUDE.md +1 -3
- package/templates/adapter-presets/claude/settings.local.json +0 -1
- package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
- package/templates/docs/AI_HANDOFF/RULES.md +2 -2
- package/templates/docs/PROJECT.md +1 -1
- package/templates/docs/UKIT_INTERNALS.md +1 -1
- package/templates/instructions/core.md +1 -3
- package/templates/instructions/layout.yaml +2 -4
- package/templates/instructions/overlays/agents.md +3 -10
- package/templates/ukit/storage/config.json +2 -10
- package/templates/adapter-presets/opencode/opencode.template.json +0 -1
|
@@ -23,10 +23,25 @@ function emptyReport() {
|
|
|
23
23
|
addedModelRoleKeys: [],
|
|
24
24
|
preservedTopLevelKeys: [],
|
|
25
25
|
preservedPatterns: [],
|
|
26
|
+
// TASK-002 / FR-002 — every `{{token}}` left in the merged output. A token
|
|
27
|
+
// survives when renderTemplateString could not resolve it (unknown variable)
|
|
28
|
+
// or when it was spliced in verbatim from the existing file. The merge never
|
|
29
|
+
// fabricates a value; it reports the token so install can warn.
|
|
30
|
+
unresolvedTokens: [],
|
|
26
31
|
parseError: null,
|
|
27
32
|
};
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
const UNRESOLVED_TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
|
|
36
|
+
|
|
37
|
+
function recordUnresolvedTokens(text, report) {
|
|
38
|
+
for (const match of text.matchAll(UNRESOLVED_TOKEN_PATTERN)) {
|
|
39
|
+
if (!report.unresolvedTokens.includes(match[1])) {
|
|
40
|
+
report.unresolvedTokens.push(match[1]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
// Extract a top-level scalar map's value span: the `key:` line plus following
|
|
31
46
|
// lines that are indented or blank. Stops before the next non-indented,
|
|
32
47
|
// non-blank line — comments preceding the next top-level key stay with it.
|
|
@@ -72,10 +87,12 @@ export function mergeOmpConfig(existingText, renderedText) {
|
|
|
72
87
|
existing = YAML.parse(existingText);
|
|
73
88
|
} catch (error) {
|
|
74
89
|
report.parseError = error?.message ?? String(error);
|
|
90
|
+
recordUnresolvedTokens(renderedText, report);
|
|
75
91
|
return { text: renderedText, report };
|
|
76
92
|
}
|
|
77
93
|
if (!isPlainObject(existing)) {
|
|
78
94
|
report.parseError = 'existing .omp/config.yml did not parse to a mapping';
|
|
95
|
+
recordUnresolvedTokens(renderedText, report);
|
|
79
96
|
return { text: renderedText, report };
|
|
80
97
|
}
|
|
81
98
|
|
|
@@ -86,6 +103,7 @@ export function mergeOmpConfig(existingText, renderedText) {
|
|
|
86
103
|
// A broken template is not a migration problem — surface it as parseError so
|
|
87
104
|
// the caller falls back to plain overwrite rather than crashing install.
|
|
88
105
|
report.parseError = `rendered template failed to parse: ${error?.message ?? error}`;
|
|
106
|
+
recordUnresolvedTokens(renderedText, report);
|
|
89
107
|
return { text: renderedText, report };
|
|
90
108
|
}
|
|
91
109
|
|
|
@@ -242,6 +260,7 @@ export function mergeOmpConfig(existingText, renderedText) {
|
|
|
242
260
|
}
|
|
243
261
|
|
|
244
262
|
const text = lines.join('\n');
|
|
263
|
+
recordUnresolvedTokens(text, report);
|
|
245
264
|
// "migrated" = the merge changed anything relative to what is on disk —
|
|
246
265
|
// includes the approval-surface rewrite itself (legacy approvalMode, prompt
|
|
247
266
|
// values) even when no user region needed splicing.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPEC FR-004 — OpenCode support was removed. Any leftover artifact is a user
|
|
2
|
+
// file: UKit never installs, repairs, merges, or deletes it (only `ukit
|
|
3
|
+
// uninstall` may). This helper is the single source of truth for detection and
|
|
4
|
+
// the canonical steer message shared by projectImportant, status, and doctor.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
// Extensible list — detection is one existsSync per known filename, no parsing.
|
|
10
|
+
const OPENCODE_ARTIFACT_NAMES = ['opencode.json'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} projectRoot
|
|
14
|
+
* @returns {string[]} absolute paths of detected OpenCode artifacts
|
|
15
|
+
*/
|
|
16
|
+
export function findOpencodeArtifacts(projectRoot) {
|
|
17
|
+
return OPENCODE_ARTIFACT_NAMES
|
|
18
|
+
.map((name) => path.join(projectRoot, name))
|
|
19
|
+
.filter((candidate) => existsSync(candidate));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Canonical steer copy. Callers must print this verbatim so every surface
|
|
24
|
+
* (status line, doctor WARN row, stderr warning) reads identically.
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} paths detected artifact paths from findOpencodeArtifacts
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function opencodeSteerMessage(paths = []) {
|
|
30
|
+
const names = (Array.isArray(paths) ? paths : [])
|
|
31
|
+
.map((entry) => path.basename(String(entry)))
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
const fileList = names.length > 0 ? names.join(' ') : 'opencode.json';
|
|
34
|
+
return `[ukit] ${fileList} detected — OpenCode is no longer supported. `
|
|
35
|
+
+ `ukit will not install, repair, or merge this file. `
|
|
36
|
+
+ `Remove it manually: rm ${fileList}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { pathExists, readJsonIfExists } from './fileOps.js';
|
|
5
|
+
import { VALID_PERMISSION_MODES } from './runtimeConfig.js';
|
|
6
|
+
import {
|
|
7
|
+
compilePermissionPolicy,
|
|
8
|
+
PERMISSION_HOSTS,
|
|
9
|
+
CANONICAL_DENY,
|
|
10
|
+
} from './permissionPolicy.js';
|
|
11
|
+
import { findOpencodeArtifacts, opencodeSteerMessage } from './opencodeSteer.js';
|
|
12
|
+
|
|
13
|
+
// TASK-006 / SPEC §5 FR-006 — unified permission report across claude/omp/codex
|
|
14
|
+
// UKit-managed surfaces. Reads the live surfaces, compiles the semantic policy
|
|
15
|
+
// (single source of truth — drift between render and doctor is impossible), and
|
|
16
|
+
// emits { declared, effective, hosts, checks, failures } for both the human
|
|
17
|
+
// doctor section and `--json`.
|
|
18
|
+
//
|
|
19
|
+
// FAIL classes (each → named failing check + report.failures entry + exit 1):
|
|
20
|
+
// unattended-prompt prompt/ask reachable while permissionMode=unattended
|
|
21
|
+
// deny-loss canonical deny entry missing from a host surface
|
|
22
|
+
// unsafe-eval-mismatch surface eval approval ≠ compiled evalPosture
|
|
23
|
+
// unrendered-token literal {{token}} left in a rendered artifact
|
|
24
|
+
// INFO: reachable prompts under interactive/safe-auto. SKIP: absent host surface.
|
|
25
|
+
|
|
26
|
+
const UNRESOLVED_TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
|
|
27
|
+
|
|
28
|
+
// Bounded scan list — UKit-managed rendered artifacts only (SPEC §6 perf).
|
|
29
|
+
const RENDERED_ARTIFACTS = [
|
|
30
|
+
'.omp/config.yml',
|
|
31
|
+
'.claude/settings.json',
|
|
32
|
+
'.claude/settings.local.json',
|
|
33
|
+
'.codex/settings.json',
|
|
34
|
+
'.codex/settings.local.json',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
async function readOmpConfig(projectRoot) {
|
|
38
|
+
const configPath = path.join(projectRoot, '.omp', 'config.yml');
|
|
39
|
+
try {
|
|
40
|
+
const text = await fs.readFile(configPath, 'utf8');
|
|
41
|
+
const parsed = parse(text);
|
|
42
|
+
return { exists: true, parsed: parsed && typeof parsed === 'object' ? parsed : {}, parseError: null };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error?.code === 'ENOENT') return { exists: false, parsed: null, parseError: null };
|
|
45
|
+
return { exists: true, parsed: null, parseError: error?.message ?? String(error) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readClaudeSurface(projectRoot) {
|
|
50
|
+
const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
|
|
51
|
+
const settings = await readJsonIfExists(settingsPath);
|
|
52
|
+
const permissions = settings?.permissions;
|
|
53
|
+
if (!permissions || typeof permissions !== 'object') {
|
|
54
|
+
return { present: false, deny: [], allow: [], ask: [] };
|
|
55
|
+
}
|
|
56
|
+
const list = (key) => (Array.isArray(permissions[key]) ? permissions[key].filter((e) => typeof e === 'string') : []);
|
|
57
|
+
return {
|
|
58
|
+
present: true,
|
|
59
|
+
deny: list('deny'),
|
|
60
|
+
allow: list('allow'),
|
|
61
|
+
ask: list('ask'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function scanUnresolvedTokens(projectRoot) {
|
|
66
|
+
const hits = [];
|
|
67
|
+
for (const relPath of RENDERED_ARTIFACTS) {
|
|
68
|
+
let text;
|
|
69
|
+
try {
|
|
70
|
+
text = await fs.readFile(path.join(projectRoot, relPath), 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
continue; // absent artifact — not a token hit
|
|
73
|
+
}
|
|
74
|
+
for (const match of text.matchAll(UNRESOLVED_TOKEN_PATTERN)) {
|
|
75
|
+
hits.push({ file: relPath, token: match[1] });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return hits;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function inspectPermissions({ projectRoot, ompPath = 'omp' }) {
|
|
82
|
+
const runtimeConfig = await readJsonIfExists(
|
|
83
|
+
path.join(projectRoot, '.ukit', 'storage', 'config.json'),
|
|
84
|
+
);
|
|
85
|
+
const declaredRaw = runtimeConfig?.orchestration?.permissionMode;
|
|
86
|
+
const declared = declaredRaw === undefined ? 'unattended' : declaredRaw;
|
|
87
|
+
const modeValid = VALID_PERMISSION_MODES.has(declared);
|
|
88
|
+
const allowUnsafeEval = runtimeConfig?.orchestration?.allowUnsafeEval === true;
|
|
89
|
+
|
|
90
|
+
// --- Surface reads ---------------------------------------------------------
|
|
91
|
+
const omp = await readOmpConfig(projectRoot);
|
|
92
|
+
const ompTools = omp.parsed?.tools ?? {};
|
|
93
|
+
const approvalMap = ompTools.approval && typeof ompTools.approval === 'object' ? ompTools.approval : {};
|
|
94
|
+
const bashPatterns = Array.isArray(omp.parsed?.bash?.patterns) ? omp.parsed.bash.patterns : [];
|
|
95
|
+
|
|
96
|
+
const ompDeny = bashPatterns
|
|
97
|
+
.filter((e) => e && e.approval === 'deny' && typeof e.match === 'string')
|
|
98
|
+
.map((e) => e.match);
|
|
99
|
+
const ompAllow = bashPatterns
|
|
100
|
+
.filter((e) => e && e.approval === 'allow' && typeof e.match === 'string')
|
|
101
|
+
.map((e) => e.match);
|
|
102
|
+
// Reachable prompt/ask entries on the omp surface — named so the report can
|
|
103
|
+
// point at the offending key instead of a bare count.
|
|
104
|
+
const ompPromptEntries = [];
|
|
105
|
+
if (ompTools.approvalMode === 'prompt' || ompTools.approvalMode === 'ask') {
|
|
106
|
+
ompPromptEntries.push('tools.approvalMode');
|
|
107
|
+
}
|
|
108
|
+
for (const [key, value] of Object.entries(approvalMap)) {
|
|
109
|
+
if (value === 'prompt' || value === 'ask') ompPromptEntries.push(`tools.approval.${key}`);
|
|
110
|
+
}
|
|
111
|
+
for (const entry of bashPatterns) {
|
|
112
|
+
if (entry && (entry.approval === 'prompt' || entry.approval === 'ask')) {
|
|
113
|
+
ompPromptEntries.push(`bash.patterns:${entry.match ?? '?'}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const ompEvalSurface = approvalMap.eval;
|
|
117
|
+
|
|
118
|
+
const claude = await readClaudeSurface(projectRoot);
|
|
119
|
+
const codexPresent = await pathExists(path.join(projectRoot, '.codex'));
|
|
120
|
+
|
|
121
|
+
// --- Per-host compiled policy ---------------------------------------------
|
|
122
|
+
const hosts = {};
|
|
123
|
+
const policies = {};
|
|
124
|
+
const surfaceInput = {
|
|
125
|
+
omp: omp.exists
|
|
126
|
+
? {
|
|
127
|
+
deny: ompDeny,
|
|
128
|
+
allow: ompAllow,
|
|
129
|
+
ask: ompPromptEntries,
|
|
130
|
+
// compile throws on non-allow/deny values — prompt/ask eval is already
|
|
131
|
+
// counted as a reachable prompt, so only feed the compiler a real
|
|
132
|
+
// posture value.
|
|
133
|
+
...(ompEvalSurface === 'allow' || ompEvalSurface === 'deny'
|
|
134
|
+
? { evalApproval: ompEvalSurface }
|
|
135
|
+
: {}),
|
|
136
|
+
}
|
|
137
|
+
: null,
|
|
138
|
+
claude: claude.present
|
|
139
|
+
? { deny: claude.deny, allow: claude.allow, ask: claude.ask }
|
|
140
|
+
: null,
|
|
141
|
+
codex: codexPresent ? {} : null,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (const host of PERMISSION_HOSTS) {
|
|
145
|
+
const present = surfaceInput[host] !== null;
|
|
146
|
+
if (!present) {
|
|
147
|
+
hosts[host] = { present: false, enforcement: host === 'codex' ? 'report-only' : 'enforced' };
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
let policy = null;
|
|
151
|
+
let compileError = null;
|
|
152
|
+
if (modeValid) {
|
|
153
|
+
try {
|
|
154
|
+
policy = compilePermissionPolicy({
|
|
155
|
+
permissionMode: declared,
|
|
156
|
+
allowUnsafeEval,
|
|
157
|
+
host,
|
|
158
|
+
surfaces: surfaceInput[host],
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
compileError = error?.message ?? String(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
policies[host] = policy;
|
|
165
|
+
|
|
166
|
+
const surfaceDeny = surfaceInput[host]?.deny ?? [];
|
|
167
|
+
const denySet = new Set(surfaceDeny);
|
|
168
|
+
const denyMissing = CANONICAL_DENY
|
|
169
|
+
.map((c) => c[host])
|
|
170
|
+
.filter((entry) => typeof entry === 'string' && !denySet.has(entry));
|
|
171
|
+
|
|
172
|
+
const promptCount = host === 'omp'
|
|
173
|
+
? ompPromptEntries.length
|
|
174
|
+
: (surfaceInput[host]?.ask ?? []).length;
|
|
175
|
+
|
|
176
|
+
hosts[host] = {
|
|
177
|
+
present: true,
|
|
178
|
+
enforcement: policy?.enforcement ?? (host === 'codex' ? 'report-only' : 'enforced'),
|
|
179
|
+
effectiveMode: policy?.effectiveMode ?? declared,
|
|
180
|
+
allowsPrompts: policy?.allowsPrompts ?? null,
|
|
181
|
+
promptCount,
|
|
182
|
+
promptEntries: surfaceInput[host]?.ask ?? [],
|
|
183
|
+
denyCount: surfaceDeny.length,
|
|
184
|
+
denyMissing,
|
|
185
|
+
evalPosture: policy?.evalPosture ?? null,
|
|
186
|
+
evalSurface: host === 'omp' ? (ompEvalSurface ?? null) : null,
|
|
187
|
+
warnings: policy?.warnings ?? [],
|
|
188
|
+
...(compileError ? { compileError } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const unresolvedTokens = await scanUnresolvedTokens(projectRoot);
|
|
193
|
+
const legacyArtifacts = findOpencodeArtifacts(projectRoot);
|
|
194
|
+
|
|
195
|
+
// --- Checks ----------------------------------------------------------------
|
|
196
|
+
const failures = [];
|
|
197
|
+
const anySurface = PERMISSION_HOSTS.some((h) => hosts[h].present);
|
|
198
|
+
const totalPrompts = PERMISSION_HOSTS.reduce((n, h) => n + (hosts[h].promptCount ?? 0), 0);
|
|
199
|
+
const promptsFail = declared === 'unattended' && totalPrompts > 0;
|
|
200
|
+
// Report-only hosts (codex) have no permission surface by design — counting
|
|
201
|
+
// their canonical deny set as "missing" would make deny-loss FAIL permanent
|
|
202
|
+
// with an impossible remedy. Only enforced surfaces feed deny-loss.
|
|
203
|
+
const denyMissingAll = PERMISSION_HOSTS
|
|
204
|
+
.filter((h) => hosts[h].enforcement === 'enforced')
|
|
205
|
+
.flatMap((h) => (hosts[h].denyMissing ?? []).map((entry) => `${h}:${entry}`));
|
|
206
|
+
const evalMismatch = PERMISSION_HOSTS.some((h) =>
|
|
207
|
+
(hosts[h].warnings ?? []).includes('unsafe-eval-mismatch'));
|
|
208
|
+
const driftWarnings = PERMISSION_HOSTS.flatMap((h) =>
|
|
209
|
+
(hosts[h].warnings ?? [])
|
|
210
|
+
.filter((w) => w !== 'unsafe-eval-mismatch' && w !== 'prompt-reachable-under-unattended')
|
|
211
|
+
.map((w) => `${h}:${w}`));
|
|
212
|
+
|
|
213
|
+
if (promptsFail) failures.push('unattended-prompt');
|
|
214
|
+
if (denyMissingAll.length > 0) failures.push('deny-loss');
|
|
215
|
+
if (evalMismatch) failures.push('unsafe-eval-mismatch');
|
|
216
|
+
if (unresolvedTokens.length > 0) failures.push('unrendered-token');
|
|
217
|
+
if (!modeValid) failures.push('invalid-permission-mode');
|
|
218
|
+
|
|
219
|
+
const check = (label, passed, remediationClass, remedy, extra = {}) => ({
|
|
220
|
+
label,
|
|
221
|
+
passed,
|
|
222
|
+
failed: !passed,
|
|
223
|
+
remediationClass,
|
|
224
|
+
remedy,
|
|
225
|
+
...extra,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const checks = [
|
|
229
|
+
check(
|
|
230
|
+
'permission mode declared and valid',
|
|
231
|
+
modeValid,
|
|
232
|
+
'owner-action',
|
|
233
|
+
`Set orchestration.permissionMode in .ukit/storage/config.json to one of: ${[...VALID_PERMISSION_MODES].join(', ')}.`,
|
|
234
|
+
{ detail: `declared: ${declared}${declaredRaw === undefined ? ' (default)' : ''} → effective: ${modeValid ? declared : 'unknown'}` },
|
|
235
|
+
),
|
|
236
|
+
check(
|
|
237
|
+
'no prompts reachable under unattended mode',
|
|
238
|
+
!promptsFail,
|
|
239
|
+
'owner-action',
|
|
240
|
+
'Run ukit install to strip prompt/ask approvals, or switch orchestration.permissionMode to interactive/safe-auto.',
|
|
241
|
+
{
|
|
242
|
+
...(anySurface ? {} : { applicable: false, detail: 'no host permission surfaces installed' }),
|
|
243
|
+
...(anySurface && !promptsFail && totalPrompts > 0
|
|
244
|
+
? { severity: 'info' }
|
|
245
|
+
: {}),
|
|
246
|
+
detail: anySurface
|
|
247
|
+
? `${totalPrompts} reachable prompt/ask entr(ies)${declared === 'unattended' ? '' : ` (mode ${declared} allows prompts)`}`
|
|
248
|
+
: 'no host permission surfaces installed',
|
|
249
|
+
},
|
|
250
|
+
),
|
|
251
|
+
check(
|
|
252
|
+
'deny coverage vs canonical set',
|
|
253
|
+
denyMissingAll.length === 0,
|
|
254
|
+
'install-repairable',
|
|
255
|
+
'Run ukit install to restore the canonical deny entries on host surfaces.',
|
|
256
|
+
{
|
|
257
|
+
...(anySurface ? {} : { applicable: false }),
|
|
258
|
+
detail: denyMissingAll.length === 0
|
|
259
|
+
? 'all canonical deny entries present'
|
|
260
|
+
: `missing: ${denyMissingAll.join(', ')}`,
|
|
261
|
+
},
|
|
262
|
+
),
|
|
263
|
+
check(
|
|
264
|
+
'eval posture matches allowUnsafeEval',
|
|
265
|
+
!evalMismatch,
|
|
266
|
+
'install-repairable',
|
|
267
|
+
`Run ukit install to re-render eval approval (compiled posture: ${allowUnsafeEval ? 'allow' : 'deny'}), or flip orchestration.allowUnsafeEval.`,
|
|
268
|
+
{
|
|
269
|
+
...(hosts.omp.present ? {} : { applicable: false, detail: '.omp/config.yml absent' }),
|
|
270
|
+
detail: hosts.omp.present
|
|
271
|
+
? `knob=${allowUnsafeEval} → expected ${allowUnsafeEval ? 'allow' : 'deny'}, surface eval=${ompEvalSurface ?? 'absent'}`
|
|
272
|
+
: '.omp/config.yml absent',
|
|
273
|
+
},
|
|
274
|
+
),
|
|
275
|
+
check(
|
|
276
|
+
'no unresolved {{token}} in rendered artifacts',
|
|
277
|
+
unresolvedTokens.length === 0,
|
|
278
|
+
'install-repairable',
|
|
279
|
+
'Run ukit install to re-render and heal literal {{tokens}}; unknown tokens are reported, never fabricated.',
|
|
280
|
+
{
|
|
281
|
+
detail: unresolvedTokens.length === 0
|
|
282
|
+
? 'no {{...}} literals in managed artifacts'
|
|
283
|
+
: unresolvedTokens.map((t) => `${t.file}:{{${t.token}}}`).join(', '),
|
|
284
|
+
},
|
|
285
|
+
),
|
|
286
|
+
check(
|
|
287
|
+
'no drift vs compiled policy',
|
|
288
|
+
driftWarnings.length === 0,
|
|
289
|
+
'advisory',
|
|
290
|
+
'Review the flagged surface entries against compilePermissionPolicy output.',
|
|
291
|
+
{
|
|
292
|
+
...(driftWarnings.length > 0 ? { severity: 'warning' } : {}),
|
|
293
|
+
detail: driftWarnings.length === 0 ? 'surfaces consistent with compiled policy' : driftWarnings.join(', '),
|
|
294
|
+
},
|
|
295
|
+
),
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
if (legacyArtifacts.length > 0) {
|
|
299
|
+
checks.push({
|
|
300
|
+
label: opencodeSteerMessage(legacyArtifacts),
|
|
301
|
+
passed: true,
|
|
302
|
+
failed: false,
|
|
303
|
+
severity: 'warning',
|
|
304
|
+
remediationClass: 'advisory',
|
|
305
|
+
remedy: 'Remove it manually: rm opencode.json',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
declared,
|
|
311
|
+
effective: modeValid ? declared : 'unknown',
|
|
312
|
+
allowUnsafeEval,
|
|
313
|
+
hosts,
|
|
314
|
+
checks,
|
|
315
|
+
failures,
|
|
316
|
+
unresolvedTokens,
|
|
317
|
+
legacyArtifacts,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// UKIT_EPOLICY — pure compiler: orchestration.permissionMode +
|
|
2
|
+
// orchestration.allowUnsafeEval → deterministic per-host semantic policy for
|
|
3
|
+
// claude/omp/codex. No I/O: callers pass parsed surfaces (doctor reads files,
|
|
4
|
+
// tests pass fixtures). Consumed by doctor (FR-006) and the no-freeze matrix
|
|
5
|
+
// (FR-007) as the single semantic source of truth so host surfaces cannot
|
|
6
|
+
// drift. SPEC §5 FR-005 / §8 / §14.
|
|
7
|
+
import { VALID_PERMISSION_MODES } from './runtimeConfig.js';
|
|
8
|
+
|
|
9
|
+
export const PERMISSION_HOSTS = Object.freeze(['claude', 'omp', 'codex']);
|
|
10
|
+
export const PERMISSION_MODES = VALID_PERMISSION_MODES;
|
|
11
|
+
|
|
12
|
+
// Precedence — higher tier always wins over a lower one for the same entry.
|
|
13
|
+
export const PERMISSION_PRECEDENCE = Object.freeze([
|
|
14
|
+
'hard-deny', // catastrophic commands — deny regardless of any surface
|
|
15
|
+
'protected-guard', // hook-guarded files/tools — outranks host-deny
|
|
16
|
+
'host-deny', // surface deny rules (settings.json deny, bash.patterns deny)
|
|
17
|
+
'explicit-allow', // surface allow rules
|
|
18
|
+
'mode-fallback', // mode default action for unclassified entries
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Canonical deny set — semantic ids + per-host surface strings. Templates,
|
|
22
|
+
// doctor, and the matrix all assert against this so surfaces cannot lose an
|
|
23
|
+
// entry without detection. `hardDeny: true` entries are catastrophic-class
|
|
24
|
+
// (root/home/parent recursive deletes, dd/mkfs/fork-bomb) and are denied in
|
|
25
|
+
// every mode on every host, even report-only codex.
|
|
26
|
+
export const CANONICAL_DENY = Object.freeze([
|
|
27
|
+
{ id: 'git-reset-hard', claude: 'Bash(git reset --hard:*)', omp: 'git reset --hard*', codex: 'git reset --hard' },
|
|
28
|
+
{ id: 'git-push-force', claude: 'Bash(git push --force:*)', omp: 'git push --force*', codex: 'git push --force' },
|
|
29
|
+
{ id: 'git-push-force-short', claude: 'Bash(git push -f:*)', omp: 'git push -f *', codex: 'git push -f' },
|
|
30
|
+
{ id: 'git-push-force-with-lease', claude: 'Bash(git push --force-with-lease:*)', omp: 'git push --force-with-lease*', codex: 'git push --force-with-lease' },
|
|
31
|
+
{ id: 'git-clean-fd', claude: 'Bash(git clean -fd:*)', omp: 'git clean -fd*', codex: 'git clean -fd' },
|
|
32
|
+
{ id: 'git-checkout-dot', claude: 'Bash(git checkout .:*)', omp: 'git checkout .*', codex: 'git checkout .' },
|
|
33
|
+
{ id: 'git-restore-dot', claude: 'Bash(git restore .:*)', omp: 'git restore .*', codex: 'git restore .' },
|
|
34
|
+
{ id: 'rm-rf-root', claude: 'Bash(rm -rf /:*)', omp: 'rm -rf /*', codex: 'rm -rf /', hardDeny: true },
|
|
35
|
+
{ id: 'rm-rf-home', claude: 'Bash(rm -rf ~:*)', omp: 'rm -rf ~*', codex: 'rm -rf ~', hardDeny: true },
|
|
36
|
+
{ id: 'rm-rf-parent', claude: 'Bash(rm -rf ..:*)', omp: 'rm -rf ..', codex: 'rm -rf ..', hardDeny: true },
|
|
37
|
+
{ id: 'rm-rf-dot', claude: 'Bash(rm -rf .:*)', omp: 'rm -rf .', codex: 'rm -rf .', hardDeny: true },
|
|
38
|
+
{ id: 'rm-rf-any', claude: 'Bash(rm -rf:*)', omp: 'rm -rf *', codex: 'rm -rf' },
|
|
39
|
+
{ id: 'dd-raw-device', claude: 'Bash(dd if=/dev/:*)', omp: 'dd if=/dev/*', codex: 'dd if=/dev/', hardDeny: true },
|
|
40
|
+
{ id: 'write-raw-device', claude: 'Bash(*> /dev/sda:*)', omp: '*> /dev/sda*', codex: '> /dev/sda', hardDeny: true },
|
|
41
|
+
{ id: 'mkfs', claude: 'Bash(mkfs:*)', omp: 'mkfs.*', codex: 'mkfs', hardDeny: true },
|
|
42
|
+
{ id: 'fork-bomb', claude: 'Bash(:(){ :|:& };::*)', omp: ':(){ :|:& };:*', codex: ':(){ :|:& };:', hardDeny: true },
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Canonical protected-guard set — hook-enforced paths/tools that outrank any
|
|
46
|
+
// surface deny/allow. Semantic ids only; hook scripts remain the authority.
|
|
47
|
+
export const CANONICAL_PROTECTED = Object.freeze([
|
|
48
|
+
'release-credentials', // npm tokens, signing keys, .env secrets
|
|
49
|
+
'protected-files', // protect-files.sh guarded paths
|
|
50
|
+
'stale-spec-guard', // spec/source drift guard on edits
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const TIER_RANK = new Map(PERMISSION_PRECEDENCE.map((tier, i) => [tier, i]));
|
|
54
|
+
|
|
55
|
+
// Mode fallback for unclassified entries. `prompt` fallback is what makes a
|
|
56
|
+
// mode prompt-reachable; unattended never falls back to a prompt.
|
|
57
|
+
const MODE_FALLBACK = Object.freeze({
|
|
58
|
+
interactive: 'prompt',
|
|
59
|
+
'safe-auto': 'prompt',
|
|
60
|
+
unattended: 'allow',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const MODE_PROMPT_CAPABLE = Object.freeze({
|
|
64
|
+
interactive: true,
|
|
65
|
+
'safe-auto': true,
|
|
66
|
+
unattended: false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// codex carries no UKit-managed permission block today — the compiler still
|
|
70
|
+
// returns a shaped policy so doctor/matrix can report it (SPEC §14).
|
|
71
|
+
const HOST_ENFORCEMENT = Object.freeze({
|
|
72
|
+
claude: 'enforced',
|
|
73
|
+
omp: 'enforced',
|
|
74
|
+
codex: 'report-only',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function policyError(message) {
|
|
78
|
+
return new Error(`UKIT_EPOLICY ${message}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* compilePermissionPolicy({ permissionMode, allowUnsafeEval, host, surfaces? })
|
|
83
|
+
* → { mode, effectiveMode, host, enforcement, allowsPrompts, promptReachable,
|
|
84
|
+
* deny, allow, protections, evalPosture, rules, warnings }
|
|
85
|
+
*
|
|
86
|
+
* `surfaces` is an optional caller-parsed host surface:
|
|
87
|
+
* { deny: [...], allow: [...], protect: [...], ask: [...], evalApproval }
|
|
88
|
+
* Entries appearing in several tiers resolve to the highest precedence tier.
|
|
89
|
+
*/
|
|
90
|
+
export function compilePermissionPolicy(input = {}) {
|
|
91
|
+
const { permissionMode, allowUnsafeEval = false, host, surfaces = {} } = input;
|
|
92
|
+
|
|
93
|
+
if (!VALID_PERMISSION_MODES.has(permissionMode)) {
|
|
94
|
+
throw policyError(`unknown permissionMode: ${JSON.stringify(permissionMode)} (expected one of: ${[...VALID_PERMISSION_MODES].join(', ')})`);
|
|
95
|
+
}
|
|
96
|
+
if (!PERMISSION_HOSTS.includes(host)) {
|
|
97
|
+
throw policyError(`unknown host: ${JSON.stringify(host)} (expected one of: ${PERMISSION_HOSTS.join(', ')})`);
|
|
98
|
+
}
|
|
99
|
+
if (allowUnsafeEval !== undefined && typeof allowUnsafeEval !== 'boolean') {
|
|
100
|
+
throw policyError(`allowUnsafeEval must be boolean, got: ${JSON.stringify(allowUnsafeEval)}`);
|
|
101
|
+
}
|
|
102
|
+
if (surfaces === null || typeof surfaces !== 'object' || Array.isArray(surfaces)) {
|
|
103
|
+
throw policyError('surfaces must be an object when provided');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const mode = permissionMode;
|
|
107
|
+
const effectiveMode = mode;
|
|
108
|
+
const enforcement = HOST_ENFORCEMENT[host];
|
|
109
|
+
const promptCapable = MODE_PROMPT_CAPABLE[mode];
|
|
110
|
+
const warnings = [];
|
|
111
|
+
|
|
112
|
+
// --- eval posture: allow iff the knob is explicitly true (fail closed). ---
|
|
113
|
+
const evalPosture = allowUnsafeEval === true ? 'allow' : 'deny';
|
|
114
|
+
if (surfaces.evalApproval !== undefined
|
|
115
|
+
&& surfaces.evalApproval !== 'allow'
|
|
116
|
+
&& surfaces.evalApproval !== 'deny') {
|
|
117
|
+
throw policyError(`surfaces.evalApproval must be 'allow' or 'deny', got: ${JSON.stringify(surfaces.evalApproval)}`);
|
|
118
|
+
}
|
|
119
|
+
if (surfaces.evalApproval !== undefined && surfaces.evalApproval !== evalPosture) {
|
|
120
|
+
warnings.push('unsafe-eval-mismatch');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Tier collection. Each rule: { entry, tier, action, source }. ---
|
|
124
|
+
const byEntry = new Map();
|
|
125
|
+
const addRule = (entry, tier, action, source) => {
|
|
126
|
+
if (typeof entry !== 'string' || entry.length === 0) return;
|
|
127
|
+
const existing = byEntry.get(entry);
|
|
128
|
+
if (!existing || TIER_RANK.get(tier) < TIER_RANK.get(existing.tier)) {
|
|
129
|
+
byEntry.set(entry, { entry, tier, action, source });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const canonical of CANONICAL_DENY) {
|
|
134
|
+
const tier = canonical.hardDeny === true ? 'hard-deny' : 'host-deny';
|
|
135
|
+
addRule(canonical[host], tier, 'deny', `canonical:${canonical.id}`);
|
|
136
|
+
}
|
|
137
|
+
for (const guard of CANONICAL_PROTECTED) {
|
|
138
|
+
addRule(guard, 'protected-guard', 'deny', 'canonical-guard');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const entry of surfaces.protect ?? []) addRule(entry, 'protected-guard', 'deny', 'surface:protect');
|
|
142
|
+
for (const entry of surfaces.deny ?? []) addRule(entry, 'host-deny', 'deny', 'surface:deny');
|
|
143
|
+
for (const entry of surfaces.allow ?? []) addRule(entry, 'explicit-allow', 'allow', 'surface:allow');
|
|
144
|
+
|
|
145
|
+
// ask/prompt surface entries: reachable prompts only when the mode can prompt.
|
|
146
|
+
const askEntries = [...new Set(surfaces.ask ?? [])];
|
|
147
|
+
const promptReachable = [];
|
|
148
|
+
for (const entry of askEntries) {
|
|
149
|
+
if (promptCapable) {
|
|
150
|
+
addRule(entry, 'explicit-allow', 'prompt', 'surface:ask');
|
|
151
|
+
promptReachable.push(entry);
|
|
152
|
+
} else {
|
|
153
|
+
// Under unattended an ask entry is unreachable — flagged for doctor.
|
|
154
|
+
addRule(entry, 'host-deny', 'deny', 'surface:ask-unreachable');
|
|
155
|
+
warnings.push('prompt-reachable-under-unattended');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const rules = [...byEntry.values()];
|
|
160
|
+
const deny = rules.filter((r) => r.action === 'deny').map((r) => r.entry);
|
|
161
|
+
const allow = rules.filter((r) => r.action === 'allow').map((r) => r.entry);
|
|
162
|
+
const protections = rules.filter((r) => r.tier === 'protected-guard').map((r) => r.entry);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
mode,
|
|
166
|
+
effectiveMode,
|
|
167
|
+
host,
|
|
168
|
+
enforcement,
|
|
169
|
+
allowsPrompts: promptCapable,
|
|
170
|
+
promptReachable,
|
|
171
|
+
deny,
|
|
172
|
+
allow,
|
|
173
|
+
protections,
|
|
174
|
+
evalPosture,
|
|
175
|
+
rules,
|
|
176
|
+
warnings: [...new Set(warnings)],
|
|
177
|
+
fallback: MODE_FALLBACK[mode],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
isSensitiveDataGateEnabled,
|
|
26
26
|
loadSensitiveAllowlist,
|
|
27
27
|
} from './sensitiveValueScanner.js';
|
|
28
|
+
import { findOpencodeArtifacts, opencodeSteerMessage } from './opencodeSteer.js';
|
|
28
29
|
|
|
29
30
|
export const PROJECT_IMPORTANT_FILENAME = 'PROJECT_IMPORTANT.md';
|
|
30
31
|
export const PROJECT_IMPORTANT_CODEPOINT_LIMIT = 6000;
|
|
@@ -338,12 +339,14 @@ export async function renderProjectImportantForProject(projectRoot, options = {}
|
|
|
338
339
|
* TASK-044 — adapter wiring facts for status/doctor (spec §13/§14).
|
|
339
340
|
*
|
|
340
341
|
* Returns install + wiring facts only — never file bodies. Claude wiring is
|
|
341
|
-
* always expected; omp/Codex
|
|
342
|
-
* absent adapter is `installed:false`, never a failure.
|
|
342
|
+
* always expected; omp/Codex are checked only when installed, and an
|
|
343
|
+
* absent adapter is `installed:false`, never a failure. A leftover
|
|
344
|
+
* `opencode.json` is no longer an adapter signal — it is detected and
|
|
345
|
+
* reported via `opencodeArtifacts` + a steer warning, never wired.
|
|
343
346
|
*
|
|
344
347
|
* @param {string} projectRoot
|
|
345
348
|
* @param {{ trackedPaths?: string[] }} [options] install.json file list, if known
|
|
346
|
-
* @returns {Promise<{claude: object, omp: object, codex: object,
|
|
349
|
+
* @returns {Promise<{claude: object, omp: object, codex: object, opencodeArtifacts: string[], agentsFallback: boolean}>}
|
|
347
350
|
*/
|
|
348
351
|
export async function inspectProjectImportantWiring(projectRoot, options = {}) {
|
|
349
352
|
const trackedPaths = Array.isArray(options.trackedPaths) ? options.trackedPaths : [];
|
|
@@ -397,7 +400,7 @@ export async function inspectProjectImportantWiring(projectRoot, options = {}) {
|
|
|
397
400
|
wired: runtimeModule && hookInstalled && hookExecutable && settingsWired,
|
|
398
401
|
};
|
|
399
402
|
|
|
400
|
-
// --- AGENTS.md owner-instructions pointer (shared Codex
|
|
403
|
+
// --- AGENTS.md owner-instructions pointer (shared Codex fallback) ---
|
|
401
404
|
let agentsFallback = false;
|
|
402
405
|
try {
|
|
403
406
|
const agents = await readFile(agentsPath, 'utf8');
|
|
@@ -425,9 +428,12 @@ export async function inspectProjectImportantWiring(projectRoot, options = {}) {
|
|
|
425
428
|
const codexInstalled = tracked('.codex/') || await exists(path.join(projectRoot, '.codex', 'settings.json'));
|
|
426
429
|
const codex = { installed: codexInstalled, wired: codexInstalled && agentsFallback };
|
|
427
430
|
|
|
428
|
-
// --- OpenCode
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
+
// --- OpenCode steer (SPEC FR-005) — a leftover opencode.json is a user file:
|
|
432
|
+
// never wired, never installed/repaired. Detect it and steer toward removal.
|
|
433
|
+
const opencodeArtifacts = findOpencodeArtifacts(projectRoot);
|
|
434
|
+
if (opencodeArtifacts.length > 0) {
|
|
435
|
+
console.warn(opencodeSteerMessage(opencodeArtifacts));
|
|
436
|
+
}
|
|
431
437
|
|
|
432
|
-
return { claude, omp, codex,
|
|
438
|
+
return { claude, omp, codex, opencodeArtifacts, agentsFallback };
|
|
433
439
|
}
|