nccgs 2.0.0 → 2.1.1
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/.claude/agents/nccgs-adversarial-reviewer.md +1 -1
- package/.claude/agents/nccgs-engine-programmer.md +1 -1
- package/.claude/agents/nccgs-fast-implementer.md +1 -1
- package/.claude/agents/nccgs-game-designer.md +1 -1
- package/.claude/agents/nccgs-gameplay-programmer.md +1 -1
- package/.claude/agents/nccgs-qa-engineer.md +1 -1
- package/.claude/agents/nccgs-task-scout.md +1 -1
- package/.claude/agents/nccgs-ui-programmer.md +1 -1
- package/.claude/agents/nccgs-unity-build-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-implementer.md +1 -1
- package/.claude/agents/nccgs-unity-systems-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-ui-specialist.md +1 -1
- package/.claude/agents/nccgs-verification-engineer.md +1 -1
- package/.claude/nccgs/MEMORY.md +9 -3
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/nccgs/constitution.md +187 -176
- package/.claude/nccgs/hooks/closure-guard.mjs +2 -0
- package/.claude/nccgs/hooks/unity-transport-guard.mjs +11 -0
- package/.claude/nccgs/pipeline.json +260 -260
- package/.claude/nccgs/protocols/context-packets.md +5 -0
- package/.claude/nccgs/protocols/model-routing.md +46 -46
- package/.claude/nccgs/protocols/review-handoff.md +21 -1
- package/.claude/nccgs/protocols/shared-workflow-context.md +33 -0
- package/.claude/nccgs/protocols/unity-boundary.md +20 -0
- package/.claude/nccgs/routing.generated.md +4 -4
- package/.claude/nccgs/settings.fragment.json +84 -24
- package/.claude/nccgs/studio.json +53 -53
- package/.claude/nccgs/tools/autonomy.mjs +13 -0
- package/.claude/nccgs/tools/command-tokens.mjs +22 -0
- package/.claude/nccgs/tools/compile-policy.mjs +4 -0
- package/.claude/nccgs/tools/configure-models.mjs +4 -0
- package/.claude/nccgs/tools/hook-transport.mjs +34 -0
- package/.claude/nccgs/tools/installation-guard.mjs +55 -0
- package/.claude/nccgs/tools/native-completion.mjs +10 -1
- package/.claude/nccgs/tools/returned-handoff.mjs +11 -2
- package/.claude/nccgs/tools/shared-context-guard.mjs +60 -0
- package/.claude/nccgs/tools/shared-context.mjs +25 -0
- package/.claude/nccgs/tools/tool-guard.mjs +120 -131
- package/.claude/nccgs/tools/unity-transport.mjs +170 -0
- package/.claude/nccgs/workflow-catalog.json +4 -4
- package/CHANGELOG.md +235 -165
- package/CLAUDE.md +10 -8
- package/MEMORY.md +109 -56
- package/README.md +206 -168
- package/UPGRADING.md +130 -112
- package/VERSION +1 -1
- package/docs/ARCHITECTURE.md +131 -131
- package/docs/ASSET-PRODUCTION-GRAPHIFY-PLAN.md +230 -0
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +117 -87
- package/docs/NCCGS-1.5.md +237 -237
- package/docs/PROJECT-POLICY.md +94 -94
- package/docs/RELEASE-STATUS.md +32 -7
- package/docs/UNINSTALL.md +134 -0
- package/docs/WFK17-HOOK-REPAIR.md +71 -0
- package/docs/WORKFLOWS.md +85 -85
- package/package.json +1 -1
- package/scaffold/.nccgs/project.yaml +109 -108
- package/scripts/benchmark-v15.mjs +173 -173
- package/scripts/cli.mjs +11 -2
- package/scripts/doctor.mjs +261 -261
- package/scripts/install.mjs +530 -515
- package/scripts/recover-session.mjs +89 -0
- package/scripts/run.mjs +22 -9
- package/scripts/supervise.mjs +4 -0
- package/scripts/uninstall.mjs +292 -0
- package/scripts/validate.mjs +285 -278
- package/tests/autonomy.test.mjs +126 -113
- package/tests/design-readiness.test.mjs +199 -199
- package/tests/fixtures/compiler-shutdown.cs +67 -64
- package/tests/fixtures/native-provider.mjs +61 -0
- package/tests/framework.test.mjs +740 -740
- package/tests/hook-repair.test.mjs +106 -0
- package/tests/lifecycle-v15.test.mjs +346 -300
- package/tests/native-completion-notices.test.mjs +181 -0
- package/tests/phase1.test.mjs +427 -416
- package/tests/phase2.test.mjs +250 -250
- package/tests/policy-v15.test.mjs +234 -234
- package/tests/returned-handoff-notices.test.mjs +170 -0
- package/tests/runtime-v15.test.mjs +1197 -1191
- package/tests/task-slices.test.mjs +285 -285
- package/tests/uninstall.test.mjs +477 -0
- package/tests/unity-transport.test.mjs +348 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import {spawnSync} from "node:child_process";
|
|
7
|
+
import {fileURLToPath} from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import {launchPlan} from "../scripts/run.mjs";
|
|
10
|
+
import {supervise} from "../scripts/supervise.mjs";
|
|
11
|
+
import {guardTool} from "../.claude/nccgs/tools/tool-guard.mjs";
|
|
12
|
+
import {initializeStandalone} from "../.claude/nccgs/tools/lifecycle.mjs";
|
|
13
|
+
import {reserve} from "../.claude/nccgs/tools/orchestration.mjs";
|
|
14
|
+
import {observe} from "../.claude/nccgs/tools/runtime.mjs";
|
|
15
|
+
import {assertStrictUnityCliArgs,unityCliMcpArgs} from "../.claude/nccgs/tools/unity-transport.mjs";
|
|
16
|
+
import {permissionModeArgs} from "../.claude/nccgs/tools/autonomy.mjs";
|
|
17
|
+
|
|
18
|
+
// Offline synthetic fixtures: admission is not actual tool/provider execution.
|
|
19
|
+
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),"..");
|
|
20
|
+
function fixture() {
|
|
21
|
+
const base=fs.realpathSync(os.tmpdir()),project=fs.mkdtempSync(path.join(base,"nccgs-cli-transport-"));
|
|
22
|
+
const write=(rel,value)=>{const f=path.join(project,rel);fs.mkdirSync(path.dirname(f),{recursive:true});fs.writeFileSync(f,typeof value==="string"||Buffer.isBuffer(value)?value:JSON.stringify(value));return f;};
|
|
23
|
+
for(const file of ["studio.json","pipeline.json"])write(".claude/nccgs/"+file,fs.readFileSync(path.join(root,".claude/nccgs",file)));
|
|
24
|
+
write(".claude/nccgs/install-state.json",{});
|
|
25
|
+
write(".nccgs/project.yaml","schema_version: 2\nmodels:\n profile: claude-standalone\n");
|
|
26
|
+
write(".nccgs/execution.json",{schemaVersion:1,name:"standalone",externalReview:{mode:"off"},acceptanceRequired:false,requireObservedEffort:false,maxParallelWorkers:2});
|
|
27
|
+
write(".nccgs/requirements.yaml","schema_version: 1\nrequirements: []\n");
|
|
28
|
+
write("Assets/synthetic.cs","// Synthetic admission fixture, not an engine project.\n");
|
|
29
|
+
const env={...process.env};for(const k of ["CLAUDECODE","CLAUDE_CODE_ENTRYPOINT","NCCGS_PROJECT_ROOT","NCCGS_TASK_AUTONOMY"])delete env[k];
|
|
30
|
+
const plan=(...args)=>launchPlan(["--project",project,"--dry-run","--",...args],env);
|
|
31
|
+
const worker=()=>{
|
|
32
|
+
write(".nccgs/intake.json",{schemaVersion:1,mode:"implement",objective:"Synthetic transport guard regression",scope:"local",target:"fix",readiness:"ready",requiredChecks:["synthetic"],acceptance:[]});
|
|
33
|
+
initializeStandalone(project,"synthetic",{intake:".nccgs/intake.json"});
|
|
34
|
+
write(".nccgs/runtime/bindings/synthetic-session.json",{taskId:"synthetic"});
|
|
35
|
+
const d=reserve(project,"synthetic",{kind:"implementation",agent:"nccgs-fast-implementer",requestKey:"synthetic",reason:"Synthetic admission only",writePaths:["Assets/synthetic.cs"]});
|
|
36
|
+
guardTool(project,{session_id:"synthetic-session",tool_name:"Agent",tool_use_id:"synthetic-use",tool_input:{subagent_type:d.agent,prompt:`[NCCGS-DISPATCH:${d.id}] synthetic`}});
|
|
37
|
+
observe(project,{session_id:"synthetic-session",agent_id:"synthetic-worker",agent_type:d.agent},"agent_start");
|
|
38
|
+
return (name,input)=>guardTool(project,{session_id:"synthetic-session",agent_id:"synthetic-worker",tool_name:name,tool_input:input});
|
|
39
|
+
};
|
|
40
|
+
return {project,write,env,plan,worker,dispose(){assert.equal(path.dirname(fs.realpathSync(project)),base);assert.ok(path.basename(project).startsWith("nccgs-cli-transport-"));assert.equal(fs.lstatSync(project).isSymbolicLink(),false);fs.rmSync(project,{recursive:true,force:true});}};
|
|
41
|
+
}
|
|
42
|
+
test("launcher isolates MCP by default and preserves prompt/model arguments",()=>{
|
|
43
|
+
const f=fixture();try{
|
|
44
|
+
const p=f.plan("--print","literal ; $(data)","--model","sonnet");
|
|
45
|
+
assert.equal(p.args.filter(x=>x==="--strict-mcp-config").length,1);
|
|
46
|
+
assert.equal(p.args[p.args.indexOf("--mcp-config")+1],'{"mcpServers":{}}');
|
|
47
|
+
// R08 intentional order change: owned prefix, strict flag, then caller arguments.
|
|
48
|
+
assert.deepEqual(p.args.slice(0,3),["--permission-mode","default","--strict-mcp-config"]);
|
|
49
|
+
assert.deepEqual(p.args.slice(3,7),["--print","literal ; $(data)","--model","sonnet"]);
|
|
50
|
+
}finally{f.dispose();}
|
|
51
|
+
});
|
|
52
|
+
test("launcher accepts explicit non-Unity MCP JSON and configuration files",()=>{
|
|
53
|
+
const f=fixture();try{
|
|
54
|
+
const cfg=JSON.stringify({mcpServers:{graphify:{command:"node",args:["approved-server.mjs"]}}});
|
|
55
|
+
for(const arg of [cfg,f.write("approved-mcp.json",cfg)]){
|
|
56
|
+
const p=f.plan("--strict-mcp-config","--mcp-config",arg);
|
|
57
|
+
assert.equal(p.args.filter(x=>x==="--strict-mcp-config").length,1);
|
|
58
|
+
assert.equal(p.args[p.args.indexOf("--mcp-config")+1],arg);
|
|
59
|
+
}
|
|
60
|
+
}finally{f.dispose();}
|
|
61
|
+
});
|
|
62
|
+
for(const [label,server]of Object.entries({
|
|
63
|
+
coplay:{unityMCP:{command:"uvx",args:["--from","mcpforunityserver==10.1.2","mcp-for-unity"]}},
|
|
64
|
+
disguised:{other:{command:"uvx",args:["--from","mcpforunityserver","mcp-for-unity"]}},
|
|
65
|
+
official:{editor:{command:"unity",args:["mcp"]}},
|
|
66
|
+
officialPath:{editor:{command:"C:/Program Files/Unity/unity.exe",args:["mcp"]}},
|
|
67
|
+
anklebreaker:{legacy:{command:"npx",args:["com.anklebreaker.unity-mcp"]}},
|
|
68
|
+
remote:{other:{url:"https://unity-mcp.example.test/mcp"}},
|
|
69
|
+
remoteCoplay:{other:{url:"https://coplay.dev/mcp"}}
|
|
70
|
+
}))test("launcher rejects Unity MCP configuration: "+label,()=>{
|
|
71
|
+
const f=fixture();try{assert.throws(()=>f.plan("--mcp-config="+JSON.stringify({mcpServers:server})),/Unity CLI-only/);}finally{f.dispose();}
|
|
72
|
+
});
|
|
73
|
+
test("launcher rejects malformed, duplicate and weakening MCP options",()=>{
|
|
74
|
+
const f=fixture();try{
|
|
75
|
+
for(const args of [["--strict-mcp-config=false"],["--strict-mcp-config","--strict-mcp-config"],["--mcp-config"],["--mcp-config","missing.json"],["--mcp-config","{broken"],["--mcp-config","[]"],["--mcp-config",'{"mcpServers":[]}'],["--mcp-config",'{"mcpServers":{"notes":false}}'],["--mcp-config",'{"mcpServers":{}}',"--mcp-config",'{"mcpServers":{}}']])
|
|
76
|
+
assert.throws(()=>f.plan(...args),/Unity CLI-only/);
|
|
77
|
+
}finally{f.dispose();}
|
|
78
|
+
});
|
|
79
|
+
const forbiddenShell=[
|
|
80
|
+
"unity mcp","unity mcp configure codex","UNITY.EXE MCP --project-path .",'& "C:\\Program Files\\Unity\\unity.exe" mcp',
|
|
81
|
+
"echo before; unity mcp configure claude",'cmd /c "unity mcp configure codex"',"pwsh -Command 'unity mcp'",
|
|
82
|
+
"bash -c 'unity mcp'","& $env:UNITY_CLI mcp",'unity m`cp configure codex',
|
|
83
|
+
"uvx --from mcpforunityserver==10.1.2 mcp-for-unity --transport stdio","uv tool run mcp-for-unity",
|
|
84
|
+
"pip install mcpforunityserver","npx unity-mcp","python -m mcpforunityserver","mcp-for-unity --transport http",
|
|
85
|
+
"git clone https://github.com/CoplayDev/unity-mcp.git", "& { unity mcp configure codex }",
|
|
86
|
+
"${UNITY_CLI} mcp", "echo before\nunity mcp", 'Start-Process unity -ArgumentList "mcp configure codex"',
|
|
87
|
+
"Invoke-Expression 'unity mcp'"
|
|
88
|
+
];
|
|
89
|
+
test("correlated implementation rejects Unity MCP tools and registration shells",()=>{
|
|
90
|
+
const f=fixture();try{
|
|
91
|
+
const admit=f.worker();
|
|
92
|
+
for(const tool of ["mcp__unityMCP__get_state","mcp__UnityMCP__read_console","mcp__unity__execute","mcp__plugin_unity_editor__query","mcp__Coplay__inspect","mcp__anklebreaker__scene"])
|
|
93
|
+
assert.throws(()=>admit(tool,{}),/Unity CLI-only/);
|
|
94
|
+
for(const command of forbiddenShell)assert.throws(()=>admit("PowerShell",{command}),/Unity CLI-only/,command);
|
|
95
|
+
for(const command of ["unity status --format json","unity run . --format json","unity test . --mode EditMode",'& "C:\\Program Files\\Unity\\unity.exe" command eval code',"rg mcpforunityserver README.md","Write-Output 'unity mcp configure codex'","git diff -- unity-mcp-notes.md"])
|
|
96
|
+
assert.doesNotThrow(()=>admit("PowerShell",{command}),command);
|
|
97
|
+
assert.doesNotThrow(()=>admit("mcp__graphify__context",{}));
|
|
98
|
+
// The transport checker permits documentary data; lifecycle ownership still blocks a worker report.
|
|
99
|
+
assert.throws(()=>admit("PowerShell",{command:"node .claude/nccgs/tools/task.mjs report --id synthetic --summary \"CoplayDev/unity-mcp refused\""}),/Coordinator owns lifecycle commands/);
|
|
100
|
+
}finally{f.dispose();}
|
|
101
|
+
});
|
|
102
|
+
test("transport hook rejects Unity MCP before task/profile setup but permits direct CLI",()=>{
|
|
103
|
+
const f=fixture();try{
|
|
104
|
+
const hook=path.join(root,".claude/nccgs/hooks/unity-transport-guard.mjs");
|
|
105
|
+
for(const input of [{tool_name:"mcp__unityMCP__instances",tool_input:{}},...forbiddenShell.map(command=>({tool_name:"Bash",tool_input:{command}}))]){
|
|
106
|
+
const r=spawnSync(process.execPath,[hook],{cwd:f.project,input:JSON.stringify(input),encoding:"utf8"});
|
|
107
|
+
assert.equal(r.status,2,r.stderr);assert.match(r.stderr,/Unity CLI-only/);
|
|
108
|
+
}
|
|
109
|
+
const good=spawnSync(process.execPath,[hook],{cwd:f.project,input:JSON.stringify({tool_name:"PowerShell",tool_input:{command:"unity build . --format json"}}),encoding:"utf8"});
|
|
110
|
+
assert.equal(good.status,0,good.stderr);
|
|
111
|
+
const bad=spawnSync(process.execPath,[hook],{input:"{invalid",encoding:"utf8"});assert.equal(bad.status,2);
|
|
112
|
+
}finally{f.dispose();}
|
|
113
|
+
});
|
|
114
|
+
test("supervisor refuses unsafe retained arguments without editing its run or task budgets",async()=>{
|
|
115
|
+
const f=fixture();try{
|
|
116
|
+
const id=crypto.randomUUID(),rel=`.nccgs/runs/${id}/run.json`;
|
|
117
|
+
const state={schemaVersion:1,id,project:f.project,binary:process.execPath,permissionMode:"guarded",providerArgs:["--print","old retained arguments"],taskId:"historical-task",cumulativeCostUsd:3.5,invocations:[{status:"finished"}]};
|
|
118
|
+
const file=f.write(rel,state),before=fs.readFileSync(file);
|
|
119
|
+
await assert.rejects(supervise({project:f.project,binary:process.execPath,mode:"guarded",env:f.env,supervision:{recoverRun:id}}),/strict MCP isolation/);
|
|
120
|
+
assert.deepEqual(fs.readFileSync(file),before);
|
|
121
|
+
assert.equal(fs.existsSync(path.join(f.project,".nccgs/runs/task-historical-task.lock")),false);
|
|
122
|
+
}finally{f.dispose();}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("supervisor revalidates an explicit file without rewriting retained state",async()=>{
|
|
126
|
+
const f=fixture();try{
|
|
127
|
+
const config=f.write("explicit-mcp.json",{mcpServers:{notes:{command:"node",args:["approved.mjs"]}}});
|
|
128
|
+
const plan=f.plan("--mcp-config",config,"--print","retained");
|
|
129
|
+
const id=crypto.randomUUID(),rel=`.nccgs/runs/${id}/run.json`;
|
|
130
|
+
const state={schemaVersion:1,id,project:f.project,binary:process.execPath,permissionMode:"guarded",providerArgs:plan.args,taskId:"historical-task",cumulativeCostUsd:3.5,invocations:[{status:"finished"}]};
|
|
131
|
+
const file=f.write(rel,state),before=fs.readFileSync(file);
|
|
132
|
+
f.write("explicit-mcp.json",{mcpServers:{editor:{command:"unity",args:["mcp"]}}});
|
|
133
|
+
await assert.rejects(supervise({project:f.project,binary:process.execPath,mode:"guarded",env:f.env,supervision:{recoverRun:id}}),/Unity MCP servers are forbidden/);
|
|
134
|
+
assert.deepEqual(fs.readFileSync(file),before);
|
|
135
|
+
assert.equal(fs.existsSync(path.join(f.project,".nccgs/runs/task-historical-task.lock")),false);
|
|
136
|
+
}finally{f.dispose();}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("review F1 keeps positional prompts outside variadic MCP configuration",()=>{
|
|
140
|
+
const f=fixture();try{
|
|
141
|
+
for(const input of [["literal prompt"],["--print","literal prompt"],["--","--literal prompt"]]){
|
|
142
|
+
const p=f.plan(...input),i=p.args.indexOf("--mcp-config");
|
|
143
|
+
assert.ok(i>=0);
|
|
144
|
+
assert.equal(p.args[i+1],'{"mcpServers":{}}');
|
|
145
|
+
assert.ok(i+2===p.args.length||p.args[i+2]==="--","No bare argument may follow the one validated variadic config");
|
|
146
|
+
assert.ok(p.args.includes(input.at(-1)),"Prompt literal must survive");
|
|
147
|
+
}
|
|
148
|
+
const p=f.plan("--mcp-config",'{"mcpServers":{}}',"second.json");
|
|
149
|
+
assert.equal(p.args.at(-1),'{"mcpServers":{}}');
|
|
150
|
+
assert.ok(p.args.indexOf("second.json")<p.args.indexOf("--mcp-config"),"An extra positional value cannot be loaded as another MCP config");
|
|
151
|
+
}finally{f.dispose();}
|
|
152
|
+
});
|
|
153
|
+
test("review F2 rejects ordinary Claude and Codex Unity MCP registration",()=>{
|
|
154
|
+
const f=fixture();try{
|
|
155
|
+
const admit=f.worker();
|
|
156
|
+
for(const command of ["claude mcp add unityMCP -- uvx mcp-for-unity",`claude mcp add-json editor '{"command":"unity","args":["mcp"]}'`,"codex mcp add unity -- unity mcp","claude mcp add editor --url https://unity-mcp.example.test/mcp","pnpm dlx unity-mcp","bunx unity-mcp","bash -lc 'unity mcp'"])
|
|
157
|
+
assert.throws(()=>admit("PowerShell",{command}),/Unity CLI-only/,command);
|
|
158
|
+
for(const command of ["claude mcp add graphify -- node approved.mjs","codex mcp add community -- node approved.mjs","claude mcp remove unityMCP"])
|
|
159
|
+
assert.doesNotThrow(()=>admit("PowerShell",{command}),command);
|
|
160
|
+
}finally{f.dispose();}
|
|
161
|
+
});
|
|
162
|
+
test("review F3 preserves non-Unity community identities and method names",()=>{
|
|
163
|
+
const f=fixture();try{
|
|
164
|
+
assert.doesNotThrow(()=>f.plan("--mcp-config",JSON.stringify({mcpServers:{community:{command:"node",args:["approved.mjs"]}}})));
|
|
165
|
+
const admit=f.worker();
|
|
166
|
+
for(const name of ["mcp__graphify__get_community","mcp__community__context","mcp__pixellab__community_assets"])
|
|
167
|
+
assert.doesNotThrow(()=>admit(name,{}),name);
|
|
168
|
+
}finally{f.dispose();}
|
|
169
|
+
});
|
|
170
|
+
// R08 R-01: strict isolation precedes every caller-controlled argument, so a
|
|
171
|
+
// forwarded value-taking option missing its value can never consume it.
|
|
172
|
+
const EMPTY='{"mcpServers":{}}';
|
|
173
|
+
function assertSafeLayout(args,label){
|
|
174
|
+
assert.deepEqual(args.slice(0,3),["--permission-mode","default","--strict-mcp-config"],label);
|
|
175
|
+
assert.equal(args.filter(x=>x==="--strict-mcp-config").length,1,label);
|
|
176
|
+
const configs=args.map((x,i)=>x==="--mcp-config"||x.startsWith("--mcp-config=")?i:-1).filter(i=>i>=0);
|
|
177
|
+
assert.equal(configs.length,1,label);
|
|
178
|
+
const i=configs[0];
|
|
179
|
+
assert.ok(i+2===args.length||args[i+2]==="--",label+": config must end argv or precede --");
|
|
180
|
+
}
|
|
181
|
+
test("R08 R-01 dangling value-taking options cannot consume strict MCP isolation",()=>{
|
|
182
|
+
const f=fixture();try{
|
|
183
|
+
for(const input of [["--model"],["--print","prompt","--append-system-prompt"],["--allowedTools"],["--add-dir"],["--model","--","literal prompt"],["--fallback-model","--mcp-config",EMPTY]]){
|
|
184
|
+
const p=f.plan(...input),label=JSON.stringify(input);
|
|
185
|
+
assertSafeLayout(p.args,label);
|
|
186
|
+
for(const option of input.filter(x=>x.startsWith("--")&&x!=="--"&&x!=="--mcp-config"&&x!=="--print"))
|
|
187
|
+
assert.notEqual(p.args[p.args.indexOf(option)+1],"--strict-mcp-config",label);
|
|
188
|
+
// Every caller token appears after the strict flag.
|
|
189
|
+
for(const token of input.filter(x=>x!=="--mcp-config"&&x!==EMPTY))assert.ok(p.args.indexOf(token)>2,label);
|
|
190
|
+
assert.doesNotThrow(()=>assertStrictUnityCliArgs(p.args,f.project,permissionModeArgs("guarded")),label);
|
|
191
|
+
}
|
|
192
|
+
// Explicit caller strict flag anywhere is normalized to the single owned position.
|
|
193
|
+
const p=f.plan("--print","x","--strict-mcp-config","--mcp-config",EMPTY);
|
|
194
|
+
assertSafeLayout(p.args,"explicit strict");
|
|
195
|
+
assert.deepEqual(p.args,["--permission-mode","default","--strict-mcp-config","--print","x","--mcp-config",EMPTY]);
|
|
196
|
+
// Positional, print and model semantics keep their caller order.
|
|
197
|
+
assert.deepEqual(f.plan("--model","sonnet","--print","hello","--","--literal").args,
|
|
198
|
+
["--permission-mode","default","--strict-mcp-config","--model","sonnet","--print","hello","--mcp-config",EMPTY,"--","--literal"]);
|
|
199
|
+
}finally{f.dispose();}
|
|
200
|
+
});
|
|
201
|
+
test("R08 R-01 retained canonical validation requires the owned prefix and strict placement",()=>{
|
|
202
|
+
const f=fixture();try{
|
|
203
|
+
const guarded=permissionModeArgs("guarded"),full=permissionModeArgs("full");
|
|
204
|
+
const ok=[...full,"--strict-mcp-config","--model","sonnet","--mcp-config",EMPTY];
|
|
205
|
+
assert.doesNotThrow(()=>assertStrictUnityCliArgs(ok,f.project,full));
|
|
206
|
+
// An R07 layout with no caller arguments is byte-identical to the canonical layout.
|
|
207
|
+
assert.doesNotThrow(()=>assertStrictUnityCliArgs([...guarded,"--strict-mcp-config","--mcp-config",EMPTY],f.project,guarded));
|
|
208
|
+
for(const [label,args,prefix,pattern] of [
|
|
209
|
+
["R07 order with caller args",[...guarded,"--print","x","--strict-mcp-config","--mcp-config",EMPTY],guarded,/directly after the fixed permission prefix/],
|
|
210
|
+
["dangling option consumed strict",[...guarded,"--model","--strict-mcp-config","--mcp-config",EMPTY],guarded,/directly after the fixed permission prefix/],
|
|
211
|
+
["no permission prefix",["--strict-mcp-config","--mcp-config",EMPTY],guarded,/directly after the fixed permission prefix/],
|
|
212
|
+
["mismatched recorded mode",[...full,"--strict-mcp-config","--mcp-config",EMPTY],guarded,/directly after the fixed permission prefix/],
|
|
213
|
+
["second strict later",[...guarded,"--strict-mcp-config","--print","--strict-mcp-config","--mcp-config",EMPTY],guarded,/Unity CLI-only/],
|
|
214
|
+
["config not at end",[...guarded,"--strict-mcp-config","--mcp-config",EMPTY,"prompt"],guarded,/canonical single-configuration boundary/],
|
|
215
|
+
["equals config form",[...guarded,"--strict-mcp-config","--mcp-config="+EMPTY],guarded,/canonical single-configuration boundary/],
|
|
216
|
+
["unowned prefix",["--permission-mode","acceptEdits","--strict-mcp-config","--mcp-config",EMPTY],["--permission-mode","acceptEdits"],/valid NCCGS permission prefix/],
|
|
217
|
+
["missing prefix argument",[...guarded,"--strict-mcp-config","--mcp-config",EMPTY],undefined,/valid NCCGS permission prefix/]])
|
|
218
|
+
assert.throws(()=>assertStrictUnityCliArgs(args,f.project,prefix),pattern,label);
|
|
219
|
+
}finally{f.dispose();}
|
|
220
|
+
});
|
|
221
|
+
function tree(directory){
|
|
222
|
+
const result={};
|
|
223
|
+
for(const entry of fs.readdirSync(directory,{withFileTypes:true,recursive:true}))
|
|
224
|
+
if(entry.isFile()){const file=path.join(entry.parentPath??entry.path,entry.name);result[path.relative(directory,file)]=fs.readFileSync(file,"base64");}
|
|
225
|
+
else if(entry.isDirectory())result[path.relative(directory,path.join(entry.parentPath??entry.path,entry.name))+"/"]="dir";
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
test("R08 R-01 direct noncanonical retained recovery refuses without changing records, locks or costs",async()=>{
|
|
229
|
+
const f=fixture();try{
|
|
230
|
+
for(const [mode,args] of [
|
|
231
|
+
["guarded",[...permissionModeArgs("guarded"),"--print","old","--strict-mcp-config","--mcp-config",EMPTY]],
|
|
232
|
+
["guarded",[...permissionModeArgs("guarded"),"--model","--strict-mcp-config","--mcp-config",EMPTY]],
|
|
233
|
+
["full",[...permissionModeArgs("guarded"),"--strict-mcp-config","--mcp-config",EMPTY]],
|
|
234
|
+
["full",["--strict-mcp-config","--print","old","--mcp-config",EMPTY]]]){
|
|
235
|
+
const id=crypto.randomUUID();
|
|
236
|
+
const state={schemaVersion:1,id,project:f.project,binary:process.execPath,permissionMode:mode,providerArgs:args,taskId:"historical-task",
|
|
237
|
+
sessionId:crypto.randomUUID(),budgetUsd:5,cumulativeCostUsd:3.5,costCoverage:"complete",maxResumes:2,invocations:[{status:"finished",allowanceUsd:1.5}],status:"blocked"};
|
|
238
|
+
f.write(`.nccgs/runs/${id}/run.json`,state);
|
|
239
|
+
const before=tree(path.join(f.project,".nccgs"));
|
|
240
|
+
await assert.rejects(supervise({project:f.project,binary:process.execPath,mode,env:f.env,supervision:{recoverRun:id}}),/Unity CLI-only.*fresh continuation without resetting spent budgets/,JSON.stringify(args));
|
|
241
|
+
assert.deepEqual(tree(path.join(f.project,".nccgs")),before,"No record, lock, binding or cost may change");
|
|
242
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(f.project,`.nccgs/runs/${id}/run.json`))).cumulativeCostUsd,3.5);
|
|
243
|
+
}
|
|
244
|
+
}finally{f.dispose();}
|
|
245
|
+
});
|
|
246
|
+
// R08 R-02: --bare and --safe-mode skip project hooks; NCCGS owns hook activation.
|
|
247
|
+
test("R08 R-02 disabled-hook flags are rejected in ordinary and supervised launch",()=>{
|
|
248
|
+
const f=fixture();try{
|
|
249
|
+
const prompt=f.write(".nccgs/prompt.txt","Synthetic");
|
|
250
|
+
const forms=["--bare","--safe-mode","--bare=true","--safe-mode=true","--bare=","--safe-mode=false"];
|
|
251
|
+
for(const form of forms){
|
|
252
|
+
assert.throws(()=>f.plan(form),/conflicting option/,form);
|
|
253
|
+
assert.throws(()=>f.plan("--print","x","--model","sonnet",form),/conflicting option/,form);
|
|
254
|
+
assert.throws(()=>launchPlan(["--project",f.project,"--",form],f.env),/conflicting option/,form);
|
|
255
|
+
assert.throws(()=>launchPlan(["--project",f.project,"--dry-run","--supervise","--task","synthetic","--prompt-file",prompt,"--session-budget-usd","1","--",form],f.env),/conflicting option/,form);
|
|
256
|
+
assert.throws(()=>launchPlan(["--project",f.project,"--dry-run","--recover-run",crypto.randomUUID(),"--",form],f.env),/conflicting option/,form);
|
|
257
|
+
}
|
|
258
|
+
// Unrelated names are not treated as aliases.
|
|
259
|
+
assert.doesNotThrow(()=>f.plan("--print","bare safe-mode"));
|
|
260
|
+
}finally{f.dispose();}
|
|
261
|
+
});
|
|
262
|
+
// R09 R08-N1: --restricted ignores project settings/hooks (CLI 2.1.316 help).
|
|
263
|
+
const RESTRICTED=["--restricted","--restricted=true","--restricted=false","--restricted="];
|
|
264
|
+
test("R09 N1 --restricted is rejected at every launch entry point in guarded and full modes",()=>{
|
|
265
|
+
const f=fixture();try{
|
|
266
|
+
const prompt=f.write(".nccgs/prompt.txt","Synthetic");
|
|
267
|
+
for(const mode of ["guarded","full"]){
|
|
268
|
+
f.write(".nccgs/autonomy.json",{schemaVersion:1,mode});
|
|
269
|
+
assert.equal(f.plan().mode,mode);
|
|
270
|
+
for(const form of RESTRICTED){
|
|
271
|
+
const label=mode+" "+form;
|
|
272
|
+
assert.throws(()=>f.plan(form),/conflicting option/,label);
|
|
273
|
+
assert.throws(()=>f.plan("--print","x","--model","sonnet",form),/conflicting option/,label);
|
|
274
|
+
assert.throws(()=>f.plan("--print","x","--",form),/conflicting option/,label);
|
|
275
|
+
assert.throws(()=>launchPlan(["--project",f.project,"--",form],f.env),/conflicting option/,label);
|
|
276
|
+
assert.throws(()=>launchPlan(["--project",f.project,"--dry-run","--supervise","--task","synthetic","--prompt-file",prompt,"--session-budget-usd","1","--",form],f.env),/conflicting option/,label);
|
|
277
|
+
assert.throws(()=>launchPlan(["--project",f.project,"--dry-run","--recover-run",crypto.randomUUID(),"--",form],f.env),/conflicting option/,label);
|
|
278
|
+
assert.throws(()=>launchPlan(["--project",f.project,"--recover-run",crypto.randomUUID(),"--",form],f.env),/conflicting option/,label);
|
|
279
|
+
}
|
|
280
|
+
// R08 protections and positive controls remain in this mode.
|
|
281
|
+
for(const form of ["--bare","--safe-mode=false"])assert.throws(()=>f.plan(form),/conflicting option/,mode+" "+form);
|
|
282
|
+
const owned=permissionModeArgs(mode);
|
|
283
|
+
for(const input of [["--print","restricted"],["--print","--restricted mode"],["restricted project settings"],["--restricted-mode"],["--restrictedx"],["--unrestricted"],["--print","x","--","restricted"]]){
|
|
284
|
+
const p=f.plan(...input);
|
|
285
|
+
assert.deepEqual(p.args.slice(0,3),[...owned,"--strict-mcp-config"],JSON.stringify(input));
|
|
286
|
+
for(const token of input)assert.ok(p.args.includes(token),JSON.stringify(input));
|
|
287
|
+
assert.doesNotThrow(()=>assertStrictUnityCliArgs(p.args,f.project,owned),JSON.stringify(input));
|
|
288
|
+
}
|
|
289
|
+
const cfg=f.write("approved-mcp.json",JSON.stringify({mcpServers:{graphify:{command:"node",args:["approved-server.mjs"]}}}));
|
|
290
|
+
const p=f.plan("--print","restricted review","--mcp-config",cfg);
|
|
291
|
+
assert.equal(p.args.at(-1),cfg);
|
|
292
|
+
assert.doesNotThrow(()=>assertStrictUnityCliArgs(p.args,f.project,owned));
|
|
293
|
+
}
|
|
294
|
+
}finally{f.dispose();}
|
|
295
|
+
});
|
|
296
|
+
test("R09 N1 canonical stored arguments carrying --restricted are rejected by the shared policy",()=>{
|
|
297
|
+
const f=fixture();try{
|
|
298
|
+
for(const mode of ["guarded","full"]){
|
|
299
|
+
const owned=permissionModeArgs(mode);
|
|
300
|
+
for(const form of [...RESTRICTED,"--bare","--safe-mode=true","--settings=x.json"]){
|
|
301
|
+
for(const caller of [[form],["--print","x",form],["--model","sonnet",form,"--","literal"],["--print","x","--",form]]){
|
|
302
|
+
// Proves the record is R08-canonical: the unchanged layout builder reproduces it exactly.
|
|
303
|
+
const args=unityCliMcpArgs(caller,f.project,owned),label=mode+" "+JSON.stringify(caller);
|
|
304
|
+
assert.deepEqual(args.slice(0,3),[...owned,"--strict-mcp-config"],label);
|
|
305
|
+
assert.throws(()=>assertStrictUnityCliArgs(args,f.project,owned),/Unity CLI-only: retained arguments contain NCCGS-owned launch option .*fresh continuation without resetting spent budgets/,label);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Only the fixed owned prefix is skipped; positive controls still pass.
|
|
309
|
+
for(const caller of [[],["--print","restricted"],["--restricted-mode"],["--print","x","--","restricted"]])
|
|
310
|
+
assert.doesNotThrow(()=>assertStrictUnityCliArgs(unityCliMcpArgs(caller,f.project,owned),f.project,owned),JSON.stringify(caller));
|
|
311
|
+
// A second permission option after the owned prefix is not skipped.
|
|
312
|
+
assert.throws(()=>assertStrictUnityCliArgs([...owned,"--strict-mcp-config",...owned,"--mcp-config",EMPTY],f.project,owned),/NCCGS-owned launch option --permission-mode/);
|
|
313
|
+
}
|
|
314
|
+
}finally{f.dispose();}
|
|
315
|
+
});
|
|
316
|
+
test("R09 N1 supervise({recoverRun}) refuses a retained --restricted record before any lock, binding, run write or cost change",async()=>{
|
|
317
|
+
const f=fixture();try{
|
|
318
|
+
for(const mode of ["guarded","full"])for(const form of RESTRICTED){
|
|
319
|
+
const owned=permissionModeArgs(mode),id=crypto.randomUUID(),taskId="historical-task",sessionId=crypto.randomUUID();
|
|
320
|
+
const args=unityCliMcpArgs(["--print","old",form],f.project,owned);
|
|
321
|
+
const state={schemaVersion:1,id,project:f.project,binary:process.execPath,permissionMode:mode,providerArgs:args,taskId,
|
|
322
|
+
sessionId,budgetUsd:5,cumulativeCostUsd:3.5,costCoverage:"complete",maxResumes:2,invocations:[{status:"finished",allowanceUsd:1.5}],status:"blocked"};
|
|
323
|
+
f.write(`.nccgs/runs/${id}/run.json`,state);
|
|
324
|
+
const before=tree(path.join(f.project,".nccgs"));
|
|
325
|
+
await assert.rejects(supervise({project:f.project,binary:process.execPath,mode,env:f.env,supervision:{recoverRun:id}}),/NCCGS-owned launch option --restricted.*fresh continuation without resetting spent budgets/,mode+" "+form);
|
|
326
|
+
assert.deepEqual(tree(path.join(f.project,".nccgs")),before,"No record, lock, binding or cost may change");
|
|
327
|
+
assert.equal(fs.existsSync(path.join(f.project,`.nccgs/runs/task-${taskId}.lock`)),false);
|
|
328
|
+
assert.equal(fs.existsSync(path.join(f.project,`.nccgs/runtime/bindings/${sessionId}.json`)),false);
|
|
329
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(f.project,`.nccgs/runs/${id}/run.json`))),state);
|
|
330
|
+
}
|
|
331
|
+
}finally{f.dispose();}
|
|
332
|
+
});
|
|
333
|
+
test("R09 N1 positive control: a safe canonical retained record passes transport validation in supervise",async()=>{
|
|
334
|
+
const f=fixture();try{
|
|
335
|
+
const id=crypto.randomUUID(),args=unityCliMcpArgs(["--print","restricted wording only"],f.project,permissionModeArgs("guarded"));
|
|
336
|
+
f.write(`.nccgs/runs/${id}/run.json`,{schemaVersion:1,id,project:f.project,binary:process.execPath,permissionMode:"guarded",providerArgs:args,taskId:"missing-task",
|
|
337
|
+
sessionId:crypto.randomUUID(),budgetUsd:5,cumulativeCostUsd:3.5,costCoverage:"complete",maxResumes:2,invocations:[{status:"finished"}],status:"blocked"});
|
|
338
|
+
// The argv gate throws before the lock; past it, supervise records a blocked
|
|
339
|
+
// reason and returns 1. The deliberately absent task record blocks it there.
|
|
340
|
+
assert.equal(await supervise({project:f.project,binary:process.execPath,mode:"guarded",env:f.env,supervision:{recoverRun:id}}),1);
|
|
341
|
+
const after=JSON.parse(fs.readFileSync(path.join(f.project,`.nccgs/runs/${id}/run.json`)));
|
|
342
|
+
assert.equal(after.status,"blocked");
|
|
343
|
+
assert.match(after.reason,/ENOENT|missing-task/);
|
|
344
|
+
assert.doesNotMatch(after.reason,/Unity CLI-only|owned launch option/);
|
|
345
|
+
assert.equal(after.cumulativeCostUsd,3.5);
|
|
346
|
+
assert.equal(after.invocations.length,1,"No native invocation may start");
|
|
347
|
+
}finally{f.dispose();}
|
|
348
|
+
});
|